diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml
index 66fdf3e56e2..3e004a024dc 100644
--- a/.github/workflows/cla.yml
+++ b/.github/workflows/cla.yml
@@ -33,7 +33,7 @@ jobs:
with:
path-to-signatures: '${{ github.repository }}/cla.json'
path-to-document: 'https://github.com/${{ github.repository }}/blob/main/CLA.md'
- branch: 'master'
+ branch: 'main'
remote-organization-name: 'Expensify'
remote-repository-name: 'CLA'
lock-pullrequest-aftermerge: false
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 519deb5b1aa..55d4bc3ce22 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -70,6 +70,7 @@ In this scenario, it’s possible that you found a bug or enhancement that we ha
#### Propose a solution for the job
4. After you reproduce the issue, make a proposal for your solution and post it as a comment in the corresponding GitHub issue (linked in the Upwork job). Your solution proposal should include a brief technical explanation of the changes you will make.
- Note: If you post a proposed solution in an issue that has not been tagged with the `External` label, Expensify has the right to use your proposal to fix said issue, without providing compensation for your solution.
+ - Note: Before submitting a proposal on an issue, be sure to read any other existing proposals. Any new proposal should be substantively different from existing proposals.
5. Pause at this step until Expensify provides feedback on your proposal (do not begin coding or creating a pull request yet).
6. If your solution proposal is accepted, Expensify will hire you on Upwork and assign the GitHub issue to you.
diff --git a/README.md b/README.md
index 48a9fb9828a..8a0f4725d14 100644
--- a/README.md
+++ b/README.md
@@ -55,10 +55,37 @@ You can use any IDE or code editing tool for developing on any platform. Use you
## Troubleshooting
1. If you are having issues with **_Getting Started_**, please reference [React Native's Documentation](https://reactnative.dev/docs/environment-setup)
-2. If you are running into issues communicating with the API please verify your `.env` file is [set up correctly](#getting-started) for the platform you are trying to run.
+2. If you are running into CORS errors like (in the browser dev console)
+ ```sh
+ Access to fetch at 'https://www.expensify.com/api?command=GetAccountStatus' from origin 'http://localhost:8080' has been blocked by CORS policy
+ ```
+ You probably have a misconfigured `.env` file - remove it (`rm .env`) and try again
**Note:** Expensify engineers that will be testing with the API in your local dev environment please refer to [these additional instructions](https://stackoverflow.com/c/expensify/questions/7699/7700).
+## Environment variables
+Creating an `.env` file is not necessary. We advise external contributors against it. It can lead to errors when
+variables referenced here get updated since your local `.env` file is ignored.
+
+- `EXPENSIFY_URL_CASH` - The root URL used for the website
+- `EXPENSIFY_URL_SECURE` - The URL used to hit the Expensify secure API
+- `EXPENSIFY_URL_COM` - The URL used to hit the Expensify API
+- `EXPENSIFY_PARTNER_NAME` - Constant used for the app when authenticating.
+- `EXPENSIFY_PARTNER_PASSWORD` - Another constant used for the app when authenticating. (This is OK to be public)
+- `PUSHER_APP_KEY` - Key used to authenticate with Pusher.com
+- `SECURE_NGROK_URL` - Secure URL used for `ngrok` when testing
+- `NGROK_URL` - URL used for `ngrok` when testing
+- `USE_NGROK` - Flag to turn `ngrok` testing on or off
+- `USE_WDYR` - Flag to turn [`Why Did You Render`](https://github.com/welldone-software/why-did-you-render) testing on or off
+- `USE_WEB_PROXY`⚠️- Used in web/desktop development, it starts a server along the local development server to proxy
+ requests to the backend. External contributors should set this to `true` otherwise they'll have CORS errors.
+ If you don't want to start the proxy server set this explicitly to `false`
+- `CAPTURE_METRICS` (optional) - Set this to `true` to capture performance metrics and see them in Flipper
+ see [PERFORMANCE.md](PERFORMANCE.md#performance-metrics-opt-in-on-local-release-builds) for more information
+- `ONYX_METRICS` (optional) - Set this to `true` to capture even more performance metrics and see them in Flipper
+ see [React-Native-Onyx#benchmarks](https://github.com/Expensify/react-native-onyx#benchmarks) for more information
+
+
----
# Running the tests
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 8a1c353d6b3..574b44a8b7f 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -150,8 +150,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001010001
- versionName "1.1.0-1"
+ versionCode 1001010800
+ versionName "1.1.8-0"
}
splits {
abi {
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index c612507bd64..187c1ee179b 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -1,3 +1,6 @@
#FFFFFF
+ #0185ff
+ #0b1b34
+ #7D8B8F
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index cf62d64ac8a..5b0ebadd121 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -3,14 +3,13 @@
-
diff --git a/assets/images/bill.svg b/assets/images/bill.svg
new file mode 100644
index 00000000000..b27e6776b0f
--- /dev/null
+++ b/assets/images/bill.svg
@@ -0,0 +1,11 @@
+
+
+
diff --git a/assets/images/briefcase.svg b/assets/images/briefcase.svg
new file mode 100644
index 00000000000..f73854bbb36
--- /dev/null
+++ b/assets/images/briefcase.svg
@@ -0,0 +1,8 @@
+
+
+
diff --git a/assets/images/circle-hourglass.svg b/assets/images/circle-hourglass.svg
new file mode 100644
index 00000000000..1bba8b47410
--- /dev/null
+++ b/assets/images/circle-hourglass.svg
@@ -0,0 +1,10 @@
+
+
+
diff --git a/assets/images/concierge.svg b/assets/images/concierge.svg
new file mode 100644
index 00000000000..4b22f626a0e
--- /dev/null
+++ b/assets/images/concierge.svg
@@ -0,0 +1,8 @@
+
+
+
diff --git a/assets/images/confetti-pop.gif b/assets/images/confetti-pop.gif
new file mode 100644
index 00000000000..a4137b6e9dd
Binary files /dev/null and b/assets/images/confetti-pop.gif differ
diff --git a/assets/images/invoice.svg b/assets/images/invoice.svg
new file mode 100644
index 00000000000..91391b0f9bc
--- /dev/null
+++ b/assets/images/invoice.svg
@@ -0,0 +1,11 @@
+
+
+
diff --git a/assets/images/lets-chat.svg b/assets/images/lets-chat.svg
new file mode 100644
index 00000000000..b083891413a
--- /dev/null
+++ b/assets/images/lets-chat.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/luggage.svg b/assets/images/luggage.svg
new file mode 100644
index 00000000000..b7f8dc7fe93
--- /dev/null
+++ b/assets/images/luggage.svg
@@ -0,0 +1,16 @@
+
+
+
diff --git a/assets/images/paycheck.svg b/assets/images/paycheck.svg
index 228faf7cee7..f81a5a9a86a 100644
--- a/assets/images/paycheck.svg
+++ b/assets/images/paycheck.svg
@@ -1,20 +1,17 @@
-
+
diff --git a/assets/images/paypal.svg b/assets/images/paypal.svg
index 752dac9dac9..370a55b4128 100644
--- a/assets/images/paypal.svg
+++ b/assets/images/paypal.svg
@@ -2,12 +2,8 @@
diff --git a/assets/images/product-illustrations/bank-arrow--pink.svg b/assets/images/product-illustrations/bank-arrow--pink.svg
new file mode 100644
index 00000000000..c561bfd2790
--- /dev/null
+++ b/assets/images/product-illustrations/bank-arrow--pink.svg
@@ -0,0 +1,43 @@
+
+
+
diff --git a/assets/images/product-illustrations/bank-mouse--green.svg b/assets/images/product-illustrations/bank-mouse--green.svg
new file mode 100644
index 00000000000..99dfd1718c1
--- /dev/null
+++ b/assets/images/product-illustrations/bank-mouse--green.svg
@@ -0,0 +1,50 @@
+
+
+
diff --git a/assets/images/product-illustrations/bank-user--green.svg b/assets/images/product-illustrations/bank-user--green.svg
new file mode 100644
index 00000000000..676d05c3bc0
--- /dev/null
+++ b/assets/images/product-illustrations/bank-user--green.svg
@@ -0,0 +1,48 @@
+
+
+
diff --git a/assets/images/product-illustrations/concierge--blue.svg b/assets/images/product-illustrations/concierge--blue.svg
new file mode 100644
index 00000000000..d1d3fede1f6
--- /dev/null
+++ b/assets/images/product-illustrations/concierge--blue.svg
@@ -0,0 +1,20 @@
+
+
+
diff --git a/assets/images/product-illustrations/credit-cards--blue.svg b/assets/images/product-illustrations/credit-cards--blue.svg
new file mode 100644
index 00000000000..008dbd20be3
--- /dev/null
+++ b/assets/images/product-illustrations/credit-cards--blue.svg
@@ -0,0 +1,31 @@
+
+
+
diff --git a/assets/images/product-illustrations/invoice--orange.svg b/assets/images/product-illustrations/invoice--orange.svg
new file mode 100644
index 00000000000..aebd5066066
--- /dev/null
+++ b/assets/images/product-illustrations/invoice--orange.svg
@@ -0,0 +1,25 @@
+
+
+
diff --git a/assets/images/product-illustrations/jewel-box--blue.svg b/assets/images/product-illustrations/jewel-box--blue.svg
new file mode 100644
index 00000000000..b9d6a084bcb
--- /dev/null
+++ b/assets/images/product-illustrations/jewel-box--blue.svg
@@ -0,0 +1,45 @@
+
+
+
diff --git a/assets/images/product-illustrations/jewel-box--green.svg b/assets/images/product-illustrations/jewel-box--green.svg
new file mode 100644
index 00000000000..ba1cade3dcc
--- /dev/null
+++ b/assets/images/product-illustrations/jewel-box--green.svg
@@ -0,0 +1,45 @@
+
+
+
diff --git a/assets/images/product-illustrations/jewel-box--pink.svg b/assets/images/product-illustrations/jewel-box--pink.svg
new file mode 100644
index 00000000000..dd58151c913
--- /dev/null
+++ b/assets/images/product-illustrations/jewel-box--pink.svg
@@ -0,0 +1,45 @@
+
+
+
diff --git a/assets/images/product-illustrations/jewel-box--yellow.svg b/assets/images/product-illustrations/jewel-box--yellow.svg
new file mode 100644
index 00000000000..858d5b66688
--- /dev/null
+++ b/assets/images/product-illustrations/jewel-box--yellow.svg
@@ -0,0 +1,45 @@
+
+
+
diff --git a/assets/images/product-illustrations/money-envelope--blue.svg b/assets/images/product-illustrations/money-envelope--blue.svg
new file mode 100644
index 00000000000..199489af882
--- /dev/null
+++ b/assets/images/product-illustrations/money-envelope--blue.svg
@@ -0,0 +1,30 @@
+
+
+
diff --git a/assets/images/product-illustrations/money-mouse--pink.svg b/assets/images/product-illustrations/money-mouse--pink.svg
new file mode 100644
index 00000000000..72c21fc4675
--- /dev/null
+++ b/assets/images/product-illustrations/money-mouse--pink.svg
@@ -0,0 +1,30 @@
+
+
+
diff --git a/assets/images/product-illustrations/receipt--yellow.svg b/assets/images/product-illustrations/receipt--yellow.svg
new file mode 100644
index 00000000000..f40f3e0a5aa
--- /dev/null
+++ b/assets/images/product-illustrations/receipt--yellow.svg
@@ -0,0 +1,20 @@
+
+
+
diff --git a/assets/images/product-illustrations/rocket--orange.svg b/assets/images/product-illustrations/rocket--orange.svg
new file mode 100644
index 00000000000..a3bb9a67fb7
--- /dev/null
+++ b/assets/images/product-illustrations/rocket--orange.svg
@@ -0,0 +1,87 @@
+
+
+
diff --git a/assets/images/product-illustrations/tada--yellow.svg b/assets/images/product-illustrations/tada--yellow.svg
new file mode 100644
index 00000000000..037baef7def
--- /dev/null
+++ b/assets/images/product-illustrations/tada--yellow.svg
@@ -0,0 +1,56 @@
+
+
+
diff --git a/assets/images/receipt-search.svg b/assets/images/receipt-search.svg
new file mode 100644
index 00000000000..6272cfea321
--- /dev/null
+++ b/assets/images/receipt-search.svg
@@ -0,0 +1,15 @@
+
+
+
diff --git a/assets/images/request-call.svg b/assets/images/request-call.svg
new file mode 100644
index 00000000000..ed4b8fd3f53
--- /dev/null
+++ b/assets/images/request-call.svg
@@ -0,0 +1,26 @@
+
+
+
diff --git a/assets/images/users.svg b/assets/images/users.svg
index e74a77d7f4c..948998d2c35 100644
--- a/assets/images/users.svg
+++ b/assets/images/users.svg
@@ -13,12 +13,13 @@
-
-
-
-
-
+
+
+
+
diff --git a/colors.json b/colors.json
new file mode 100644
index 00000000000..3bd147e531d
--- /dev/null
+++ b/colors.json
@@ -0,0 +1,6 @@
+{
+ "onfidoPrimaryColor": "#03d47c",
+ "onfidoPrimaryButtonTextColor": "#ffffff",
+ "onfidoPrimaryButtonColorPressed": "#03c775",
+ "onfidoAndroidStatusBarColor": "#FAFAFA"
+}
diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js
index 97f9eb94826..45a2e6b73be 100644
--- a/config/webpack/webpack.common.js
+++ b/config/webpack/webpack.common.js
@@ -21,6 +21,7 @@ const includeModules = [
'react-native-onyx',
'react-native-gesture-handler',
'react-native-flipper',
+ 'react-native-google-places-autocomplete',
].join('|');
const webpackConfig = {
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index fd43e45ccbc..e3157fbd214 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -108,27 +108,38 @@ platform :ios do
build_app(
workspace: "./ios/NewExpensify.xcworkspace",
- scheme: "NewExpensify"
- )
-
- upload_to_testflight(
- api_key_path: "./ios/ios-fastlane-json-key.json",
- distribute_external: true,
- notify_external_testers: true,
- changelog: "Thank you for beta testing New Expensify, this version includes bug fixes and improvements.",
- groups: ["Beta"],
- demo_account_required: true,
- beta_app_review_info: {
- contact_email: ENV["APPLE_CONTACT_EMAIL"],
- contact_first_name: "Andrew",
- contact_last_name: "Gable",
- contact_phone: ENV["APPLE_CONTACT_PHONE"],
- demo_account_name: ENV["APPLE_DEMO_EMAIL"],
- demo_account_password: ENV["APPLE_DEMO_PASSWORD"],
- notes: "Use the account provided. Thank you for the review."
+ scheme: "NewExpensify",
+ export_options: {
+ manageAppVersionAndBuildNumber: false
}
)
+ begin
+ upload_to_testflight(
+ api_key_path: "./ios/ios-fastlane-json-key.json",
+ distribute_external: true,
+ notify_external_testers: true,
+ changelog: "Thank you for beta testing New Expensify, this version includes bug fixes and improvements.",
+ groups: ["Beta"],
+ demo_account_required: true,
+ beta_app_review_info: {
+ contact_email: ENV["APPLE_CONTACT_EMAIL"],
+ contact_first_name: "Andrew",
+ contact_last_name: "Gable",
+ contact_phone: ENV["APPLE_CONTACT_PHONE"],
+ demo_account_name: ENV["APPLE_DEMO_EMAIL"],
+ demo_account_password: ENV["APPLE_DEMO_PASSWORD"],
+ notes: "Use the account provided. Thank you for the review."
+ }
+ )
+ rescue Exception => e
+ if e.message.include? "Another build is in review"
+ UI.important("Another build is already in external beta review. Skipping external beta review submission")
+ else
+ raise
+ end
+ end
+
upload_symbols_to_crashlytics(
dsym_path: lane_context[SharedValues::DSYM_OUTPUT_PATH],
gsp_path: "./ios/GoogleService-Info.plist",
@@ -142,7 +153,7 @@ platform :ios do
api_key_path: "./ios/ios-fastlane-json-key.json",
# Skip HTMl report verification
- force: true,
+ force: true,
# VERSION will be set to the full build_number e.g. '1.0.92.0'
build_number: ENV["VERSION"],
diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj
index 0102b9f060b..b5c08101945 100644
--- a/ios/NewExpensify.xcodeproj/project.pbxproj
+++ b/ios/NewExpensify.xcodeproj/project.pbxproj
@@ -33,6 +33,7 @@
DB77016704074197AB6633BB /* GTAmericaExpMono-RgIt.otf in Resources */ = {isa = PBXBuildFile; fileRef = 5150E5D0D7F74DBA8D7C1914 /* GTAmericaExpMono-RgIt.otf */; };
E9DF872D2525201700607FDC /* AirshipConfig.plist in Resources */ = {isa = PBXBuildFile; fileRef = E9DF872C2525201700607FDC /* AirshipConfig.plist */; };
ED814D34526B415CAFA0451E /* GTAmericaExpMono-BdIt.otf in Resources */ = {isa = PBXBuildFile; fileRef = 3981452A2C7340EBBA2B9BD1 /* GTAmericaExpMono-BdIt.otf */; };
+ F0C450EA2705020500FD2970 /* colors.json in Resources */ = {isa = PBXBuildFile; fileRef = F0C450E92705020500FD2970 /* colors.json */; };
F2C9290F276DB05EEF04D160 /* libPods-NewExpensify-NewExpensifyTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 820334EEB8DDED5217A15684 /* libPods-NewExpensify-NewExpensifyTests.a */; };
/* End PBXBuildFile section */
@@ -86,6 +87,7 @@
EB3C4841B1DB1490EA09A324 /* Pods-NewExpensify.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify.release.xcconfig"; path = "Target Support Files/Pods-NewExpensify/Pods-NewExpensify.release.xcconfig"; sourceTree = ""; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
ED2971642150620600B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS12.0.sdk/System/Library/Frameworks/JavaScriptCore.framework; sourceTree = DEVELOPER_DIR; };
+ F0C450E92705020500FD2970 /* colors.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; name = colors.json; path = ../colors.json; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -165,6 +167,7 @@
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
+ F0C450E92705020500FD2970 /* colors.json */,
7041848326A8E40900E09F4D /* RCTStartupTimer.h */,
7041848426A8E47D00E09F4D /* RCTStartupTimer.m */,
18D050DF262400AF000D658B /* BridgingFile.swift */,
@@ -328,6 +331,7 @@
1E76D5222522316A005A268F /* GTAmericaExp-Light.otf in Resources */,
1E76D5232522316A005A268F /* GTAmericaExp-Medium.otf in Resources */,
1E76D5242522316A005A268F /* GTAmericaExp-Regular.otf in Resources */,
+ F0C450EA2705020500FD2970 /* colors.json in Resources */,
1E76D5252522316A005A268F /* GTAmericaExp-Thin.otf in Resources */,
425866037F4C482AAB46CB8B /* GTAmericaExp-BdIt.otf in Resources */,
6856B78873B64C44A92E51DB /* GTAmericaExp-MdIt.otf in Resources */,
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 7afb072bd77..9e53fa08b5f 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.1.0
+ 1.1.8
CFBundleSignature
????
CFBundleURLTypes
@@ -31,7 +31,7 @@
CFBundleVersion
- 1.1.0.1
+ 1.1.8.0
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 3532a7c43a1..4608af77a07 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.1.0
+ 1.1.8
CFBundleSignature
????
CFBundleVersion
- 1.1.0.1
+ 1.1.8.0
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index c6f92137584..e78bb9605b5 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -509,6 +509,8 @@ PODS:
- React-Core
- RNCPicker (1.9.11):
- React-Core
+ - RNDateTimePicker (3.5.2):
+ - React-Core
- RNFBAnalytics (12.3.0):
- Firebase/Analytics (= 8.4.0)
- React-Core
@@ -666,6 +668,7 @@ DEPENDENCIES:
- "RNCClipboard (from `../node_modules/@react-native-community/clipboard`)"
- "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)"
- "RNCPicker (from `../node_modules/@react-native-picker/picker`)"
+ - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)"
- "RNFBAnalytics (from `../node_modules/@react-native-firebase/analytics`)"
- "RNFBApp (from `../node_modules/@react-native-firebase/app`)"
- "RNFBCrashlytics (from `../node_modules/@react-native-firebase/crashlytics`)"
@@ -826,6 +829,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-masked-view/masked-view"
RNCPicker:
:path: "../node_modules/@react-native-picker/picker"
+ RNDateTimePicker:
+ :path: "../node_modules/@react-native-community/datetimepicker"
RNFBAnalytics:
:path: "../node_modules/@react-native-firebase/analytics"
RNFBApp:
@@ -956,6 +961,7 @@ SPEC CHECKSUMS:
RNCClipboard: 5e299c6df8e0c98f3d7416b86ae563d3a9f768a3
RNCMaskedView: 138134c4d8a9421b4f2bf39055a79aa05c2d47b1
RNCPicker: 6780c753e9e674065db90d9c965920516402579d
+ RNDateTimePicker: c9911be59b1f8670b9f244b85af3a7c295e175ed
RNFBAnalytics: 8ba84c2d31c64374d054c8621b998f25145ffddc
RNFBApp: 64c90ab78b6010ed5c3ade026dfe5ff6442c21fd
RNFBCrashlytics: 1de18b8cc36d9bcf86407c4a354399228cc84a61
diff --git a/package-lock.json b/package-lock.json
index e1521dc08c0..0d51c2a927d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.1.0-1",
+ "version": "1.1.8-0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -8190,6 +8190,14 @@
"resolved": "https://registry.npmjs.org/@react-native-community/clipboard/-/clipboard-1.5.1.tgz",
"integrity": "sha512-AHAmrkLEH5UtPaDiRqoULERHh3oNv7Dgs0bTC0hO5Z2GdNokAMPT5w8ci8aMcRemcwbtdHjxChgtjbeA38GBdA=="
},
+ "@react-native-community/datetimepicker": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-3.5.2.tgz",
+ "integrity": "sha512-TWRuAtr/DnrEcRewqvXMLea2oB+YF+SbtuYLHguALLxNJQLl/RFB7aTNZeF+OoH75zKFqtXECXV1/uxQUpA+sg==",
+ "requires": {
+ "invariant": "^2.2.4"
+ }
+ },
"@react-native-community/eslint-config": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@react-native-community/eslint-config/-/eslint-config-2.0.0.tgz",
@@ -8298,13 +8306,13 @@
"integrity": "sha512-Gh5O6Ng3z0/qSBhuuWuPSi24+RrkgNy5hzvEke8qjS+kbrPfSFLD6aq9B2Fdfndp/dAyfxgTi6aULUsnv847Hw=="
},
"@react-navigation/core": {
- "version": "6.0.0-next.13",
- "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-6.0.0-next.13.tgz",
- "integrity": "sha512-4UrxUfpvV5cZOEuuLhJiU5CRlyUUV5/v5ed4GsBHB/SCSu6QpINyI2zFn2FKcHfbVayzry0FEUIcvIduhYSTgg==",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-6.0.1.tgz",
+ "integrity": "sha512-mVdvBDYdz8uzLQHokmVdX/xC4rS7NIkD1FN/yaGdovVzYApAhM+UGd3w1zskjyCSyXaVHHOwV59ZGVew+84xfQ==",
"requires": {
- "@react-navigation/routers": "^6.0.0-next.4",
+ "@react-navigation/routers": "^6.0.1",
"escape-string-regexp": "^4.0.0",
- "nanoid": "^3.1.22",
+ "nanoid": "^3.1.23",
"query-string": "^7.0.0",
"react-is": "^16.13.0"
},
@@ -8317,35 +8325,28 @@
}
},
"@react-navigation/drawer": {
- "version": "6.0.0-next.17",
- "resolved": "https://registry.npmjs.org/@react-navigation/drawer/-/drawer-6.0.0-next.17.tgz",
- "integrity": "sha512-iEkusYpdLnt0KQBU1iUhIk+Bo8iRNKb6JuD1gdJ4brWRohECeY9LRdpPlG3GN/yMXYdHDXZiRjnUBjjyT9ywow==",
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/@react-navigation/drawer/-/drawer-6.1.4.tgz",
+ "integrity": "sha512-lmWV/Hxd60LFLsHESOXkDXZmzqpXHoRhBdaxk5FvQHXSBYm+ZB0bP9vq5nTCzUby513wTyM381GBujdgYWCGRA==",
"requires": {
- "@react-navigation/elements": "^1.0.0-next.17",
+ "@react-navigation/elements": "^1.1.0",
"color": "^3.1.3",
"warn-once": "^0.1.0"
- },
- "dependencies": {
- "@react-navigation/elements": {
- "version": "1.0.0-next.17",
- "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.0.0-next.17.tgz",
- "integrity": "sha512-7Uc99uMfECTH/giKWQjKq/J3kHnML6fniOoD4LTkoqgCxpWJ9WPWNDmft/a7Kijz6uTkYiFnAgH+yXIfm7gAgg=="
- }
}
},
"@react-navigation/elements": {
- "version": "1.0.0-next.17",
- "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.0.0-next.17.tgz",
- "integrity": "sha512-7Uc99uMfECTH/giKWQjKq/J3kHnML6fniOoD4LTkoqgCxpWJ9WPWNDmft/a7Kijz6uTkYiFnAgH+yXIfm7gAgg=="
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.1.0.tgz",
+ "integrity": "sha512-jZncciZPGuoP6B6f+Wpf6MYSSYy86B2HJDbFTCtT5xZV0w6V9GgCeqvSTOEAxifZrmKl8uDxsr0GrIxgQE8NxA=="
},
"@react-navigation/native": {
- "version": "6.0.0-next.13",
- "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-6.0.0-next.13.tgz",
- "integrity": "sha512-NCGw4ZuD7gK0cAHcBr+VwBEs/EKDk3dmK+CHGJ+BJ4f2NR9uZZPZ38I6A3JlDVXPmx1woXdDb+MgZevJkIjWng==",
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-6.0.2.tgz",
+ "integrity": "sha512-HDqEwgvQ4Cu16vz8jQ55lfyNK9CGbECI1wM9cPOcUa+gkOQEDZ/95VFfFjGGflXZs3ybPvGXlMC4ZAyh1CcO6w==",
"requires": {
- "@react-navigation/core": "^6.0.0-next.13",
+ "@react-navigation/core": "^6.0.1",
"escape-string-regexp": "^4.0.0",
- "nanoid": "^3.1.22"
+ "nanoid": "^3.1.23"
},
"dependencies": {
"escape-string-regexp": {
@@ -8356,21 +8357,20 @@
}
},
"@react-navigation/routers": {
- "version": "6.0.0-next.4",
- "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.0.0-next.4.tgz",
- "integrity": "sha512-Jpc5FTaz8eL6eGarYttNfE37++QmHYpyIifxtCJVmTi99WWNz8NmT80p4nLKPGU0R0ZdVcq+bPTGN3oqCUa1Iw==",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.0.1.tgz",
+ "integrity": "sha512-5ctB49rmtTRQuTSBVgqMsEzBUjPP2ByUzBjNivA7jmvk+PDCl4oZsiR8KAm/twhxe215GYThfi2vUWXKAg6EEQ==",
"requires": {
- "nanoid": "^3.1.22"
+ "nanoid": "^3.1.23"
}
},
"@react-navigation/stack": {
- "version": "6.0.0-next.25",
- "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-6.0.0-next.25.tgz",
- "integrity": "sha512-EBNa1M+U3qyr+AqC7IuXa6aLI+cngCDkrclq7ltaTTNfqVX+yOKXrVq5kOK4VyiFfhf0Hwe8hIl6aDlEpwi3wQ==",
+ "version": "6.0.7",
+ "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-6.0.7.tgz",
+ "integrity": "sha512-hxwhRZbn6zD2rInhItBeHTCPYzmurz+/8/MhtRevBEdLG0+61dik8Y+evg/mu6AsOU0WrDakTsLcHdf/9zkXzw==",
"requires": {
- "@react-navigation/elements": "^1.0.0-next.17",
+ "@react-navigation/elements": "^1.1.0",
"color": "^3.1.3",
- "react-native-iphone-x-helper": "^1.3.0",
"warn-once": "^0.1.0"
}
},
@@ -20585,12 +20585,12 @@
}
},
"color": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/color/-/color-3.1.3.tgz",
- "integrity": "sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ==",
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
+ "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
"requires": {
- "color-convert": "^1.9.1",
- "color-string": "^1.5.4"
+ "color-convert": "^1.9.3",
+ "color-string": "^1.6.0"
},
"dependencies": {
"color-convert": {
@@ -20605,6 +20605,15 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
+ },
+ "color-string": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz",
+ "integrity": "sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==",
+ "requires": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
}
}
},
@@ -32503,8 +32512,7 @@
"lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
- "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=",
- "dev": true
+ "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
},
"lodash.isequal": {
"version": "4.5.0",
@@ -34985,9 +34993,9 @@
"optional": true
},
"nanoid": {
- "version": "3.1.23",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
- "integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw=="
+ "version": "3.1.25",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.25.tgz",
+ "integrity": "sha512-rdwtIXaXCLFAQbnfqDRnI6jaRHp9fTcYBjtFKE8eezcZ7LuLjhUaQGNeMXf1HmRoCH32CLz6XwX0TtxEOS/A3Q=="
},
"nanomatch": {
"version": "1.2.13",
@@ -37142,9 +37150,9 @@
"dev": true
},
"query-string": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.0.0.tgz",
- "integrity": "sha512-Iy7moLybliR5ZgrK/1R3vjrXq03S13Vz4Rbm5Jg3EFq1LUmQppto0qtXz4vqZ386MSRjZgnTSZ9QC+NZOSd/XA==",
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.0.1.tgz",
+ "integrity": "sha512-uIw3iRvHnk9to1blJCG3BTc+Ro56CBowJXKmNNAm3RulvPBzWLRqKSiiDk+IplJhsydwtuNMHi8UGQFcCLVfkA==",
"requires": {
"decode-uri-component": "^0.2.0",
"filter-obj": "^1.1.0",
@@ -38516,6 +38524,23 @@
}
}
},
+ "react-native-google-places-autocomplete": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/react-native-google-places-autocomplete/-/react-native-google-places-autocomplete-2.4.1.tgz",
+ "integrity": "sha512-NJrzZ5zsguhTqe0C5tIW9PfxOn2wkWDiGYIBFksHzFOIIURxFPUlO0cJmfOjs5CBIDtMampgNXBdgADExBen5w==",
+ "requires": {
+ "lodash.debounce": "^4.0.8",
+ "prop-types": "^15.7.2",
+ "qs": "~6.9.1"
+ },
+ "dependencies": {
+ "qs": {
+ "version": "6.9.6",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.6.tgz",
+ "integrity": "sha512-TIRk4aqYLNoJUbd+g2lEdz5kLWIuTMRagAXxl78Q0RiVjAOugHmeKNGdd3cwo/ktpf9aL9epCfFqWDEKysUlLQ=="
+ }
+ }
+ },
"react-native-image-pan-zoom": {
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/react-native-image-pan-zoom/-/react-native-image-pan-zoom-2.1.12.tgz",
@@ -38526,11 +38551,6 @@
"resolved": "https://registry.npmjs.org/react-native-image-picker/-/react-native-image-picker-4.0.3.tgz",
"integrity": "sha512-S4a1jE4fAPDzmah/7OVTEAXGz1/wlGyClU+spygmek5rVLERR5BgwnkX3tLP/UvMQbfdPZNUbnH0hEe7su2AZg=="
},
- "react-native-iphone-x-helper": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/react-native-iphone-x-helper/-/react-native-iphone-x-helper-1.3.1.tgz",
- "integrity": "sha512-HOf0jzRnq2/aFUcdCJ9w9JGzN3gdEg0zFE4FyYlp4jtidqU03D5X7ZegGKfT1EWteR0gPBGp9ye5T5FvSWi9Yg=="
- },
"react-native-keyboard-spacer": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/react-native-keyboard-spacer/-/react-native-keyboard-spacer-0.4.1.tgz",
diff --git a/package.json b/package.json
index eed88eee4a8..524e08017ca 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.1.0-1",
+ "version": "1.1.8-0",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -41,6 +41,7 @@
"@react-native-async-storage/async-storage": "^1.15.5",
"@react-native-community/cli": "4.13.1",
"@react-native-community/clipboard": "^1.5.1",
+ "@react-native-community/datetimepicker": "^3.5.2",
"@react-native-community/netinfo": "^5.9.10",
"@react-native-community/progress-bar-android": "^1.0.4",
"@react-native-community/progress-view": "^1.2.3",
@@ -51,9 +52,9 @@
"@react-native-masked-view/masked-view": "^0.2.4",
"@react-native-picker/picker": "^1.9.11",
"@react-navigation/compat": "^5.3.15",
- "@react-navigation/drawer": "^6.0.0-next.17",
- "@react-navigation/native": "^6.0.0-next.13",
- "@react-navigation/stack": "^6.0.0-next.25",
+ "@react-navigation/drawer": "6.1.4",
+ "@react-navigation/native": "6.0.2",
+ "@react-navigation/stack": "6.0.7",
"babel-plugin-transform-remove-console": "^6.9.4",
"dotenv": "^8.2.0",
"electron-context-menu": "^2.3.0",
@@ -80,6 +81,7 @@
"react-native-config": "^1.4.0",
"react-native-document-picker": "^5.1.0",
"react-native-gesture-handler": "1.9.0",
+ "react-native-google-places-autocomplete": "^2.4.1",
"react-native-image-pan-zoom": "^2.1.12",
"react-native-image-picker": "^4.0.3",
"react-native-keyboard-spacer": "^0.4.1",
@@ -208,7 +210,7 @@
"runnerConfig": "tests/e2e/config.json",
"configurations": {
"ios.sim.debug": {
- "binaryPath": "ios/build/Build/Products/Debug-iphonesimulator/expensify.cash.app",
+ "binaryPath": "ios/build/Build/Products/Debug-iphonesimulator/new expensify.app",
"build": "xcodebuild -workspace ios/NewExpensify.xcworkspace -scheme NewExpensify -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build | xcpretty",
"type": "ios.simulator",
"name": "iPhone 11"
diff --git a/src/CONST.js b/src/CONST.js
index 206ac649f5e..5b12ef437ef 100755
--- a/src/CONST.js
+++ b/src/CONST.js
@@ -28,6 +28,7 @@ const CONST = {
MISSING_INCORPORATION_STATE: '402 Missing incorporationState in additionalData',
MISSING_INCORPORATION_TYPE: '402 Missing incorporationType in additionalData',
MAX_VALIDATION_ATTEMPTS_REACHED: 'Validation for this bank account has been disabled due to too many incorrect attempts. Please contact us.',
+ INCORRECT_VALIDATION_AMOUNTS: 'The validate code you entered is incorrect, please try again.',
},
STEP: {
// In the order they appear in the VBA flow
@@ -72,6 +73,11 @@ const CONST = {
VERIFYING: 'VERIFYING',
PENDING: 'PENDING',
},
+ MAX_LENGTH: {
+ TAX_ID_NUMBER: 9,
+ SSN: 4,
+ ZIP_CODE: 5,
+ },
},
INCORPORATION_TYPES: {
LLC: 'LLC',
@@ -90,6 +96,7 @@ const CONST = {
DEFAULT_ROOMS: 'defaultRooms',
BETA_EXPENSIFY_WALLET: 'expensifyWallet',
INTERNATIONALIZATION: 'internationalization',
+ IOU_SEND: 'sendMoney',
},
BUTTON_STATES: {
DEFAULT: 'default',
@@ -127,8 +134,8 @@ const CONST = {
PRIVACY_URL: 'https://use.expensify.com/privacy',
LICENSES_URL: 'https://use.expensify.com/licenses',
PLAY_STORE_URL: 'https://play.google.com/store/apps/details?id=com.expensify.chat&hl=en',
- ADD_SECONDARY_LOGIN_URL: '/settings?param={%22section%22:%22account%22}',
- MANAGE_CARDS_URL: '/domain_companycards',
+ ADD_SECONDARY_LOGIN_URL: 'settings?param={%22section%22:%22account%22}',
+ MANAGE_CARDS_URL: 'domain_companycards',
FEES_URL: 'https://use.expensify.com/fees',
CFPB_PREPAID_URL: 'https://cfpb.gov/prepaid',
STAGING_SECURE_URL: 'https://staging-secure.expensify.com/',
@@ -213,6 +220,7 @@ const CONST = {
},
ERROR: {
API_OFFLINE: 'session.offlineMessageRetry',
+ UNKNOWN_ERROR: 'Unknown error',
},
NETWORK: {
METHOD: {
@@ -244,6 +252,13 @@ const CONST = {
// at least 8 characters, 1 capital letter, 1 lowercase number, 1 number
PASSWORD_COMPLEXITY_REGEX_STRING: '^(?=.*[A-Z])(?=.*[0-9])(?=.*[a-z]).{8,}$',
+ PASSWORD_PAGE: {
+ ERROR: {
+ ALREADY_VALIDATED: 'Account already validated',
+ VALIDATE_CODE_FAILED: 'Validate code failed',
+ },
+ },
+
EMOJI_SPACER: 'SPACER',
LOGIN_TYPE: {
@@ -336,6 +351,7 @@ const CONST = {
SMS_NUMBER_COUNTRY_CODE: 'US',
ERROR: {
USER_CANCELLED: 'User canceled flow',
+ USER_TAPPED_BACK: 'User exited by clicking the back button.',
},
},
@@ -367,6 +383,11 @@ const CONST = {
PAYPAL_ME: 'PayPal.me',
VENMO: 'Venmo',
},
+ IOU_TYPE: {
+ SEND: 'send',
+ SPLIT: 'split',
+ REQUEST: 'request',
+ },
AMOUNT_MAX_LENGTH: 10,
},
@@ -404,11 +425,12 @@ const CONST = {
LARGE: 'large',
DEFAULT: 'default',
},
-
+ PHONE_MAX_LENGTH: 15,
REGEX: {
US_PHONE: /^\+1\d{10}$/,
DIGITS_AND_PLUS: /^\+?[0-9]*$/,
PHONE_E164_PLUS: /^\+?[1-9]\d{1,14}$/,
+ PHONE_WITH_SPECIAL_CHARS: /^[+]*[(]{0,1}[0-9]{1,3}[)]{0,1}[-\s\\./0-9]{0,12}$/,
NON_ALPHA_NUMERIC: /[^A-Za-z0-9+]/g,
PO_BOX: /\b[P|p]?(OST|ost)?\.?\s*[O|o|0]?(ffice|FFICE)?\.?\s*[B|b][O|o|0]?[X|x]?\.?\s+[#]?(\d+)\b/,
ANY_VALUE: /^.+$/,
@@ -416,6 +438,14 @@ const CONST = {
INDUSTRY_CODE: /^[0-9]{6}$/,
SSN_LAST_FOUR: /[0-9]{4}/,
NUMBER: /^[0-9]+$/,
+ CARD_NUMBER: /^[0-9]{15,16}$/,
+ CARD_SECURITY_CODE: /^[0-9]{3,4}$/,
+ CARD_EXPIRATION_DATE: /(0[1-9]|10|11|12)\/20[0-9]{2}$/,
+
+ // Adapted from: https://gist.github.com/dperini/729294
+ // eslint-disable-next-line max-len
+ HYPERLINK: /^(?:(?:(?:https?|ftp):\/\/)?)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$/i,
+
// eslint-disable-next-line max-len, no-misleading-character-class
EMOJIS: /(?:\uD83D(?:\uDC41\u200D\uD83D\uDDE8|\uDC68\u200D\uD83D[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uDC69\u200D\uD83D\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|[\ud83c\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|[\ud83c\ude32-\ude3a]|[\ud83c\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])/g,
},
diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js
index 2290eb69c3f..4317a9ac6be 100755
--- a/src/ONYXKEYS.js
+++ b/src/ONYXKEYS.js
@@ -131,4 +131,10 @@ export default {
// Stores Workspace ID that will be tied to reimbursement account during setup
REIMBURSEMENT_ACCOUNT_WORKSPACE_ID: 'reimbursementAccountWorkspaceID',
+
+ // Notifies all tabs that they should sign out and clear storage.
+ SHOULD_SIGN_OUT: 'shouldSignOut',
+
+ // Set when we are loading payment methods
+ IS_LOADING_PAYMENT_METHODS: 'isLoadingPaymentMethods',
};
diff --git a/src/ROUTES.js b/src/ROUTES.js
index 520f6f06417..71206888801 100644
--- a/src/ROUTES.js
+++ b/src/ROUTES.js
@@ -27,6 +27,7 @@ export default {
SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links',
SETTINGS_PAYMENTS: 'settings/payments',
SETTINGS_ADD_PAYPAL_ME: 'settings/payments/add-paypal-me',
+ SETTINGS_ADD_DEBIT_CARD: 'settings/payments/add-debit-card',
SETTINGS_ADD_LOGIN: 'settings/addlogin/:type',
getSettingsAddLoginRoute: type => `settings/addlogin/${type}`,
NEW_GROUP: 'new/group',
@@ -67,6 +68,7 @@ export default {
getReportDetailsRoute: reportID => `r/${reportID}/details`,
VALIDATE_LOGIN: 'v',
VALIDATE_LOGIN_WITH_VALIDATE_CODE: 'v/:accountID/:validateCode',
+ LOGIN_WITH_SHORT_LIVED_TOKEN: 'transition',
// This is a special validation URL that will take the user to /workspace/new after validation. This is used
// when linking users from e.com in order to share a session in this app.
@@ -75,17 +77,29 @@ export default {
LOGIN_WITH_VALIDATE_CODE_WORKSPACE_CARD: 'v/:accountID/:validateCode/workspace/:policyID/card',
LOGIN_WITH_VALIDATE_CODE_2FA_WORKSPACE_CARD: 'v/:accountID/:validateCode/2fa/workspace/:policyID/card',
ENABLE_PAYMENTS: 'enable-payments',
- WORKSPACE: 'workspace',
- WORKSPACE_CARD: ':policyID/card',
- WORKSPACE_PEOPLE: ':policyID/people',
- getWorkspaceCardRoute: policyID => `workspace/${policyID}/card`,
- getWorkspacePeopleRoute: policyID => `workspace/${policyID}/people`,
- getWorkspaceInviteRoute: policyID => `workspace/${policyID}/invite`,
+ WORKSPACE_NEW: 'workspace/new',
+ WORKSPACE_INITIAL: 'workspace/:policyID',
WORKSPACE_INVITE: 'workspace/:policyID/invite',
+ WORKSPACE_SETTINGS: 'workspace/:policyID/settings',
+ WORKSPACE_CARD: 'workspace/:policyID/card',
+ WORKSPACE_REIMBURSE: 'workspace/:policyID/reimburse',
+ WORKSPACE_BILLS: 'workspace/:policyID/bills',
+ WORKSPACE_INVOICES: 'workspace/:policyID/invoices',
+ WORKSPACE_TRAVEL: 'workspace/:policyID/travel',
+ WORKSPACE_MEMBERS: 'workspace/:policyID/members',
+ WORKSPACE_BANK_ACCOUNT: 'workspace/:policyID/bank-account',
+ getWorkspaceInitialRoute: policyID => `workspace/${policyID}`,
+ getWorkspaceInviteRoute: policyID => `workspace/${policyID}/invite`,
+ getWorkspaceSettingsRoute: policyID => `workspace/${policyID}/settings`,
+ getWorkspaceCardRoute: policyID => `workspace/${policyID}/card`,
+ getWorkspaceReimburseRoute: policyID => `workspace/${policyID}/reimburse`,
+ getWorkspaceBillsRoute: policyID => `workspace/${policyID}/bills`,
+ getWorkspaceInvoicesRoute: policyID => `workspace/${policyID}/invoices`,
+ getWorkspaceTravelRoute: policyID => `workspace/${policyID}/travel`,
+ getWorkspaceMembersRoute: policyID => `workspace/${policyID}/members`,
+ getWorkspaceBankAccountRoute: policyID => `workspace/${policyID}/bank-account`,
getRequestCallRoute: taskID => `request-call/${taskID}`,
REQUEST_CALL: 'request-call/:taskID',
- getWorkspaceEditorRoute: policyID => `workspace/${policyID}/edit`,
- WORKSPACE_EDITOR: 'workspace/:policyID/edit',
/**
* @param {String} route
diff --git a/src/SCREENS.js b/src/SCREENS.js
index 764965e7335..3a9754a1ee7 100644
--- a/src/SCREENS.js
+++ b/src/SCREENS.js
@@ -6,6 +6,7 @@ export default {
HOME: 'Home',
LOADING: 'Loading',
REPORT: 'Report',
+ LOG_IN_WITH_SHORT_LIVED_TOKEN: 'LogInWithShortLivedToken',
LOGIN_WITH_VALIDATE_CODE_NEW_WORKSPACE: 'LoginWithValidateCodeNewWorkspace',
LOGIN_WITH_VALIDATE_CODE_2FA_NEW_WORKSPACE: 'LoginWithValidateCode2FANewWorkspace',
LOGIN_WITH_VALIDATE_CODE_WORKSPACE_CARD: 'LoginWithValidateCodeWorkspaceCard',
diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js
index fa10e5c0f19..b88e2d58b09 100644
--- a/src/components/AddPlaidBankAccount.js
+++ b/src/components/AddPlaidBankAccount.js
@@ -24,6 +24,9 @@ import ExpensiPicker from './ExpensiPicker';
import Text from './Text';
import * as ReimbursementAccountUtils from '../libs/ReimbursementAccountUtils';
import ReimbursementAccountForm from '../pages/ReimbursementAccount/ReimbursementAccountForm';
+import getBankIcon from './Icon/BankIcons';
+import Icon from './Icon';
+import variables from '../styles/variables';
const propTypes = {
...withLocalizePropTypes,
@@ -135,8 +138,11 @@ class AddPlaidBankAccount extends React.Component {
}
const account = this.getAccounts()[this.state.selectedIndex];
+ const bankName = lodashGet(this.props.plaidBankAccounts, 'bankName');
this.props.onSubmit({
- account, plaidLinkToken: this.props.plaidLinkToken,
+ bankName,
+ account,
+ plaidLinkToken: this.props.plaidLinkToken,
});
}
@@ -177,9 +183,14 @@ class AddPlaidBankAccount extends React.Component {
{!_.isEmpty(this.props.text) && (
{this.props.text}
)}
- {/* @TODO there are a bunch of logos to incorporate here to replace this name
- https://d2k5nsl2zxldvw.cloudfront.net/images/plaid/bg_plaidLogos_12@2x.png */}
- {this.state.institution.name}
+
+
+ {this.state.institution.name}
+
{
+ const googlePlacesRef = useRef();
+ useEffect(() => {
+ googlePlacesRef.current?.setAddressText(props.value);
+ }, []);
+
+ // eslint-disable-next-line
+ const getAddressComponent = (object, field, nameType) => {
+ return _.chain(object.address_components)
+ .find(component => _.contains(component.types, field))
+ .get(nameType)
+ .value();
+ };
+
+ const validateAddressComponents = (addressComponents) => {
+ if (!addressComponents) {
+ return false;
+ }
+ if (!_.some(addressComponents, component => _.includes(component.types, 'street_number'))) {
+ // Missing Street number
+ return false;
+ }
+ if (_.some(addressComponents, component => _.includes(component.types, 'post_box'))) {
+ // Reject PO box
+ return false;
+ }
+ return true;
+ };
+
+ const saveLocationDetails = (details) => {
+ if (validateAddressComponents(details.address_components)) {
+ // Gather the values from the Google details
+ const streetNumber = getAddressComponent(details, 'street_number', 'long_name');
+ const streetName = getAddressComponent(details, 'route', 'long_name');
+ const city = getAddressComponent(details, 'locality', 'long_name');
+ const state = getAddressComponent(details, 'administrative_area_level_1', 'short_name');
+ const zipCode = getAddressComponent(details, 'postal_code', 'long_name');
+
+ // Trigger text change events for each of the individual fields being saved on the server
+ props.onChangeText('addressStreet', `${streetNumber} ${streetName}`);
+ props.onChangeText('addressCity', city);
+ props.onChangeText('addressState', state);
+ props.onChangeText('addressZipCode', zipCode);
+ } else {
+ // Clear the values associated to the address, so our validations catch the problem
+ props.onChangeText('addressStreet', null);
+ props.onChangeText('addressCity', null);
+ props.onChangeText('addressState', null);
+ props.onChangeText('addressZipCode', null);
+ }
+ };
+
+ return (
+ saveLocationDetails(details)}
+ query={{
+ key: 'AIzaSyC4axhhXtpiS-WozJEsmlL3Kg3kXucbZus',
+ language: props.preferredLocale,
+ types: 'address',
+ components: 'country:us',
+ }}
+ requestUrl={{
+ useOnPlatform: 'web',
+ url: `${CONFIG.EXPENSIFY.URL_EXPENSIFY_COM}api?command=Proxy_GooglePlaces&proxyUrl=`,
+ }}
+ textInputProps={{
+ InputComp: ExpensiTextInput,
+ label: props.label,
+ containerStyles: props.containerStyles,
+ errorText: props.errorText,
+ }}
+ styles={{
+ textInputContainer: [styles.flexColumn],
+ listView: [
+ styles.borderTopRounded,
+ styles.borderBottomRounded,
+ styles.mt1,
+ styles.overflowAuto,
+ styles.borderLeft,
+ styles.borderRight,
+ ],
+ row: [
+ styles.pv4,
+ styles.ph3,
+ styles.overflowAuto,
+ ],
+ description: [styles.googleSearchText],
+ separator: [styles.googleSearchSeparator],
+ }}
+ />
+ );
+};
+
+AddressSearch.propTypes = propTypes;
+AddressSearch.defaultProps = defaultProps;
+
+export default withLocalize(AddressSearch);
diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js
index 508a62baf88..92ab48c199a 100755
--- a/src/components/AttachmentModal.js
+++ b/src/components/AttachmentModal.js
@@ -158,6 +158,7 @@ class AttachmentModal extends PureComponent {
+ this.setState({isMenuVisible: true})}
+ disabled={this.props.isUploading}
+ >
+
+ {this.props.avatarURL
+ ? (
+
+ )
+ : (
+
+ )}
+
+ {({openPicker}) => (
+ <>
+ {
+ this.props.isUploading
+ ? (
+
-
- {this.props.avatarURL
- ? (
-
- )
- : (
-
- )}
-
- {({openPicker}) => (
- <>
- {
- this.props.isUploading
- ? (
-
-
-
-
- )
- : (
- <>
- this.setState({isMenuVisible: true})}
- >
-
- this.setState({isMenuVisible: false})}
- onItemSelected={() => this.setState({isMenuVisible: false})}
- menuItems={this.createMenuItems(openPicker)}
- anchorPosition={this.props.anchorPosition}
- animationIn="fadeInDown"
- animationOut="fadeOutUp"
- />
- >
- )
- }
- >
- )}
-
-
+
+ )
+ : (
+ <>
+
+
+
+ this.setState({isMenuVisible: false})}
+ onItemSelected={() => this.setState({isMenuVisible: false})}
+ menuItems={this.createMenuItems(openPicker)}
+ anchorPosition={this.props.anchorPosition}
+ animationIn="fadeInDown"
+ animationOut="fadeOutUp"
+ />
+ >
+ )
+ }
+ >
+ )}
+
+
+
);
}
diff --git a/src/components/ButtonWithMenu.js b/src/components/ButtonWithMenu.js
new file mode 100644
index 00000000000..a71d0c196da
--- /dev/null
+++ b/src/components/ButtonWithMenu.js
@@ -0,0 +1,109 @@
+import React, {PureComponent} from 'react';
+import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import _ from 'underscore';
+import styles from '../styles/styles';
+import Button from './Button';
+import ButtonWithDropdown from './ButtonWithDropdown';
+import PopoverMenu from './PopoverMenu';
+
+const propTypes = {
+ /** Text to display for the menu header */
+ menuHeaderText: PropTypes.string,
+
+ /** Callback to execute when the main button is pressed */
+ onPress: PropTypes.func.isRequired,
+
+ /** Callback to execute when a menu item is selected */
+ onChange: PropTypes.func,
+
+ /** Whether we should show a loading state for the main button */
+ isLoading: PropTypes.bool,
+
+ /** Should the confirmation button be disabled? */
+ isDisabled: PropTypes.bool,
+
+ /** Menu options to display */
+ /** [{text: 'Pay with Expensify', icon: Wallet}, {text: 'PayPal', icon: PayPal}, {text: 'Venmo', icon: Venmo}] */
+ options: PropTypes.arrayOf(PropTypes.shape({
+ text: PropTypes.string.isRequired,
+ icon: PropTypes.elementType,
+ iconWidth: PropTypes.number,
+ iconHeight: PropTypes.number,
+ iconDescription: PropTypes.string,
+ })).isRequired,
+};
+
+const defaultProps = {
+ onChange: () => {},
+ isLoading: false,
+ isDisabled: false,
+ menuHeaderText: '',
+};
+
+class ButtonWithMenu extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ selectedItem: props.options[0],
+ isMenuVisible: false,
+ };
+ }
+
+ setMenuVisibility(isMenuVisible) {
+ this.setState({isMenuVisible});
+ }
+
+ render() {
+ const selectedItemText = this.state.selectedItem.text;
+ return (
+
+ {this.props.options.length > 1 ? (
+ {
+ this.setMenuVisibility(true);
+ }}
+ />
+ ) : (
+
+ )}
+ {this.props.options.length > 1 && (
+ this.setMenuVisibility(false)}
+ onItemSelected={() => this.setMenuVisibility(false)}
+ anchorPosition={styles.createMenuPositionRightSidepane}
+ animationIn="fadeInUp"
+ animationOut="fadeOutDown"
+ headerText={this.props.menuHeaderText}
+ menuItems={_.map(this.props.options, item => ({
+ ...item,
+ onSelected: () => {
+ this.setState({selectedItem: item});
+ this.props.onChange(item);
+ },
+ }))}
+ />
+ )}
+
+ );
+ }
+}
+
+ButtonWithMenu.propTypes = propTypes;
+ButtonWithMenu.defaultProps = defaultProps;
+ButtonWithMenu.displayName = 'ButtonWithMenu';
+
+export default ButtonWithMenu;
diff --git a/src/components/Checkbox.js b/src/components/Checkbox.js
index 2ca33f1cd2d..afd939fa9a5 100644
--- a/src/components/Checkbox.js
+++ b/src/components/Checkbox.js
@@ -14,23 +14,32 @@ const propTypes = {
/** Should the input be styled for errors */
hasError: PropTypes.bool,
+
+ /** Should the input be disabled */
+ disabled: PropTypes.bool,
};
const defaultProps = {
hasError: false,
+ disabled: false,
};
const Checkbox = ({
isChecked,
onPress,
hasError,
+ disabled,
}) => (
- onPress(!isChecked)}>
+ onPress(!isChecked)}
+ >
diff --git a/src/components/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js
index 5e6b2f2dfb7..db2d23ccb26 100644
--- a/src/components/CheckboxWithLabel.js
+++ b/src/components/CheckboxWithLabel.js
@@ -64,6 +64,7 @@ const CheckboxWithLabel = ({
styles.w100,
styles.flexRow,
styles.flexWrap,
+ styles.flexShrink1,
styles.alignItemsCenter,
]}
>
diff --git a/src/components/CheckboxWithTooltip/CheckboxWithTooltipForMobileWebAndNative.js b/src/components/CheckboxWithTooltip/CheckboxWithTooltipForMobileWebAndNative.js
new file mode 100644
index 00000000000..22be81b5a89
--- /dev/null
+++ b/src/components/CheckboxWithTooltip/CheckboxWithTooltipForMobileWebAndNative.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import {View} from 'react-native';
+import Checkbox from '../Checkbox';
+import {propTypes, defaultProps} from './CheckboxWithTooltipPropTypes';
+import Growl from '../../libs/Growl';
+import withWindowDimensions from '../withWindowDimensions';
+
+class CheckboxWithTooltipForMobileWebAndNative extends React.Component {
+ constructor(props) {
+ super(props);
+ this.showGrowlOrTriggerOnPress = this.showGrowlOrTriggerOnPress.bind(this);
+ }
+
+ componentDidUpdate() {
+ if (this.props.toggleTooltip) {
+ Growl.show(this.props.text, this.props.growlType, 3000);
+ }
+ }
+
+ /**
+ * Show warning modal on mobile devices since tooltips are not supported when checkbox is disabled.
+ */
+ showGrowlOrTriggerOnPress() {
+ if (this.props.toggleTooltip) {
+ Growl.show(this.props.text, this.props.growlType, 3000);
+ return;
+ }
+ this.props.onPress();
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+}
+
+CheckboxWithTooltipForMobileWebAndNative.propTypes = propTypes;
+CheckboxWithTooltipForMobileWebAndNative.defaultProps = defaultProps;
+
+export default withWindowDimensions(CheckboxWithTooltipForMobileWebAndNative);
diff --git a/src/components/CheckboxWithTooltip/CheckboxWithTooltipPropTypes.js b/src/components/CheckboxWithTooltip/CheckboxWithTooltipPropTypes.js
new file mode 100644
index 00000000000..e0aa67e85b2
--- /dev/null
+++ b/src/components/CheckboxWithTooltip/CheckboxWithTooltipPropTypes.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import {windowDimensionsPropTypes} from '../withWindowDimensions';
+import CONST from '../../CONST';
+import stylePropTypes from '../../styles/stylePropTypes';
+
+const propTypes = {
+ /** Whether the checkbox is checked */
+ isChecked: PropTypes.bool.isRequired,
+
+ /** Called when the checkbox or label is pressed */
+ onPress: PropTypes.func.isRequired,
+
+ /** Flag to determine to toggle or not the tooltip */
+ toggleTooltip: PropTypes.bool,
+
+ /** The text to display in the tooltip. */
+ text: PropTypes.string.isRequired,
+
+ /** Type of the growl to be displayed in case of mobile devices */
+ growlType: PropTypes.string,
+
+ /** Container styles */
+ style: stylePropTypes,
+
+ /** Props inherited from withWindowDimensions */
+ ...windowDimensionsPropTypes,
+};
+
+const defaultProps = {
+ style: [],
+ disabled: false,
+ toggleTooltip: true,
+ growlType: CONST.GROWL.WARNING,
+};
+
+export {
+ propTypes,
+ defaultProps,
+};
diff --git a/src/components/CheckboxWithTooltip/index.js b/src/components/CheckboxWithTooltip/index.js
new file mode 100644
index 00000000000..21070ffd4d8
--- /dev/null
+++ b/src/components/CheckboxWithTooltip/index.js
@@ -0,0 +1,45 @@
+import React from 'react';
+import {View} from 'react-native';
+import CheckboxWithTooltipForMobileWebAndNative from './CheckboxWithTooltipForMobileWebAndNative';
+import Checkbox from '../Checkbox';
+import {propTypes, defaultProps} from './CheckboxWithTooltipPropTypes';
+import Tooltip from '../Tooltip';
+import withWindowDimensions from '../withWindowDimensions';
+
+const CheckboxWithTooltip = (props) => {
+ if (props.isSmallScreenWidth || props.isMediumScreenWidth) {
+ return (
+
+ );
+ }
+ const checkbox = (
+
+ );
+ return (
+
+ {props.toggleTooltip
+ ? (
+
+ {checkbox}
+
+ )
+ : checkbox}
+
+ );
+};
+
+CheckboxWithTooltip.propTypes = propTypes;
+CheckboxWithTooltip.defaultProps = defaultProps;
+CheckboxWithTooltip.displayName = 'CheckboxWithTooltip';
+
+export default withWindowDimensions(CheckboxWithTooltip);
diff --git a/src/components/CheckboxWithTooltip/index.native.js b/src/components/CheckboxWithTooltip/index.native.js
new file mode 100644
index 00000000000..8ca32848975
--- /dev/null
+++ b/src/components/CheckboxWithTooltip/index.native.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import {propTypes, defaultProps} from './CheckboxWithTooltipPropTypes';
+import withWindowDimensions from '../withWindowDimensions';
+import CheckboxWithTooltipForMobileWebAndNative from './CheckboxWithTooltipForMobileWebAndNative';
+
+const CheckboxWithTooltip = props => (
+
+);
+
+CheckboxWithTooltip.propTypes = propTypes;
+CheckboxWithTooltip.defaultProps = defaultProps;
+CheckboxWithTooltip.displayName = 'CheckboxWithTooltip';
+
+export default withWindowDimensions(CheckboxWithTooltip);
diff --git a/src/components/CommunicationsLink.js b/src/components/CommunicationsLink.js
index ab7adb0cbed..3e58f4b9cf1 100644
--- a/src/components/CommunicationsLink.js
+++ b/src/components/CommunicationsLink.js
@@ -36,6 +36,7 @@ const CommunicationsLink = props => (
{props.isSmallScreenWidth
? (
Linking.openURL(
props.type === CONST.LOGIN_TYPE.PHONE
? `tel:${props.value}`
diff --git a/src/components/CopyTextToClipboard.js b/src/components/CopyTextToClipboard.js
new file mode 100644
index 00000000000..ef721eaac69
--- /dev/null
+++ b/src/components/CopyTextToClipboard.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Text from './Text';
+import {Checkmark, Clipboard as ClipboardIcon} from './Icon/Expensicons';
+import Clipboard from '../libs/Clipboard';
+import Icon from './Icon';
+import styles from '../styles/styles';
+
+const propTypes = {
+ /** The text to display and copy to the clipboard */
+ text: PropTypes.string.isRequired,
+
+ /** Styles to apply to the text */
+ textStyles: PropTypes.arrayOf(PropTypes.object),
+};
+
+const defaultProps = {
+ textStyles: [],
+};
+
+class CopyTextToClipboard extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.copyToClipboard = this.copyToClipboard.bind(this);
+
+ this.state = {
+ showCheckmark: false,
+ };
+ }
+
+ componentWillUnmount() {
+ // Clear the interval when the component unmounts so that if the user navigates
+ // away quickly, then setState() won't try to update a component that's been unmounted
+ clearInterval(this.showCheckmarkInterval);
+ }
+
+ copyToClipboard() {
+ Clipboard.setString(this.props.text);
+ this.setState({showCheckmark: true}, () => {
+ this.showCheckmarkInterval = setTimeout(() => {
+ this.setState({showCheckmark: false});
+ }, 2000);
+ });
+ }
+
+ render() {
+ return (
+
+ {this.props.text}
+ {this.state.showCheckmark
+ ?
+ : }
+
+ );
+ }
+}
+
+CopyTextToClipboard.propTypes = propTypes;
+CopyTextToClipboard.defaultProps = defaultProps;
+
+export default CopyTextToClipboard;
diff --git a/src/components/DatePicker/datepickerPropTypes.js b/src/components/DatePicker/datepickerPropTypes.js
new file mode 100644
index 00000000000..4d2234376c2
--- /dev/null
+++ b/src/components/DatePicker/datepickerPropTypes.js
@@ -0,0 +1,22 @@
+import PropTypes from 'prop-types';
+import {
+ propTypes as fieldPropTypes,
+ defaultProps as defaultFieldPropTypes,
+} from '../ExpensiTextInput/baseExpensiTextInputPropTypes';
+
+const propTypes = {
+ ...fieldPropTypes,
+
+ /**
+ * The datepicker supports any value that `moment` can parse.
+ * `onChange` would always be called with a Date (or null)
+ */
+ value: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]),
+};
+
+const defaultProps = {
+ ...defaultFieldPropTypes,
+ value: undefined,
+};
+
+export {propTypes, defaultProps};
diff --git a/src/components/DatePicker/index.android.js b/src/components/DatePicker/index.android.js
new file mode 100644
index 00000000000..cbc726688d1
--- /dev/null
+++ b/src/components/DatePicker/index.android.js
@@ -0,0 +1,83 @@
+import React from 'react';
+import RNDatePicker from '@react-native-community/datetimepicker';
+import moment from 'moment';
+import ExpensiTextInput from '../ExpensiTextInput';
+import CONST from '../../CONST';
+import {propTypes, defaultProps} from './datepickerPropTypes';
+
+class DatePicker extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isPickerVisible: false,
+ };
+
+ this.showPicker = this.showPicker.bind(this);
+ this.raiseDateChange = this.raiseDateChange.bind(this);
+ }
+
+ /**
+ * @param {Event} event
+ */
+ showPicker(event) {
+ this.setState({isPickerVisible: true});
+ event.preventDefault();
+ }
+
+ /**
+ * @param {Event} event
+ * @param {Date} selectedDate
+ */
+ raiseDateChange(event, selectedDate) {
+ if (event.type === 'set') {
+ this.props.onChange(selectedDate);
+ }
+
+ this.setState({isPickerVisible: false});
+ }
+
+ render() {
+ const {
+ value,
+ label,
+ placeholder,
+ hasError,
+ errorText,
+ translateX,
+ containerStyles,
+ disabled,
+ } = this.props;
+
+ const dateAsText = value ? moment(value).format(CONST.DATE.MOMENT_FORMAT_STRING) : '';
+
+ return (
+ <>
+
+ {this.state.isPickerVisible && (
+
+ )}
+ >
+ );
+ }
+}
+
+DatePicker.propTypes = propTypes;
+DatePicker.defaultProps = defaultProps;
+
+export default DatePicker;
diff --git a/src/components/DatePicker/index.ios.js b/src/components/DatePicker/index.ios.js
new file mode 100644
index 00000000000..127523b267c
--- /dev/null
+++ b/src/components/DatePicker/index.ios.js
@@ -0,0 +1,139 @@
+import React from 'react';
+import {Button, View} from 'react-native';
+import RNDatePicker from '@react-native-community/datetimepicker';
+import moment from 'moment';
+import ExpensiTextInput from '../ExpensiTextInput';
+import withLocalize, {withLocalizePropTypes} from '../withLocalize';
+import Popover from '../Popover';
+import CONST from '../../CONST';
+import styles from '../../styles/styles';
+import themeColors from '../../styles/themes/default';
+import {propTypes, defaultProps} from './datepickerPropTypes';
+
+const datepickerPropTypes = {
+ ...propTypes,
+ ...withLocalizePropTypes,
+};
+
+class Datepicker extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isPickerVisible: false,
+ selectedDate: props.value ? moment(props.value).toDate() : new Date(),
+ };
+
+ this.showPicker = this.showPicker.bind(this);
+ this.reset = this.reset.bind(this);
+ this.selectDate = this.selectDate.bind(this);
+ this.updateLocalDate = this.updateLocalDate.bind(this);
+ }
+
+ /**
+ * @param {Event} event
+ */
+ showPicker(event) {
+ this.initialValue = this.state.selectedDate;
+ this.setState({isPickerVisible: true});
+ event.preventDefault();
+ }
+
+ /**
+ * Reset the date spinner to the initial value
+ */
+ reset() {
+ this.setState({selectedDate: this.initialValue});
+ }
+
+ /**
+ * Accept the current spinner changes, close the spinner and propagate the change
+ * to the parent component (props.onChange)
+ */
+ selectDate() {
+ this.setState({isPickerVisible: false});
+ this.props.onChange(this.state.selectedDate);
+ }
+
+ /**
+ * @param {Event} event
+ * @param {Date} selectedDate
+ */
+ updateLocalDate(event, selectedDate) {
+ this.setState({selectedDate});
+ }
+
+ render() {
+ const {
+ value,
+ label,
+ placeholder,
+ hasError,
+ errorText,
+ translateX,
+ containerStyles,
+ disabled,
+ } = this.props;
+
+ const dateAsText = value ? moment(value).format(CONST.DATE.MOMENT_FORMAT_STRING) : '';
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+ }
+}
+
+Datepicker.propTypes = datepickerPropTypes;
+Datepicker.defaultProps = defaultProps;
+
+/**
+ * We're applying localization here because we present a modal (with buttons) ourselves
+ * Furthermore we're passing the locale down so that the modal and the date spinner are in the same
+ * locale. Otherwise the spinner would be present in the system locale and it would be weird if it happens
+ * that the modal buttons are in one locale (app) while the (spinner) month names are another (system)
+ */
+export default withLocalize(Datepicker);
diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js
new file mode 100644
index 00000000000..3688fbc6f4a
--- /dev/null
+++ b/src/components/DatePicker/index.js
@@ -0,0 +1,97 @@
+import React from 'react';
+import moment from 'moment';
+import ExpensiTextInput from '../ExpensiTextInput';
+import CONST from '../../CONST';
+import {propTypes, defaultProps} from './datepickerPropTypes';
+import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions';
+import './styles.css';
+
+const datePickerPropTypes = {
+ ...propTypes,
+ ...windowDimensionsPropTypes,
+};
+
+class Datepicker extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.raiseDateChange = this.raiseDateChange.bind(this);
+ this.showDatepicker = this.showDatepicker.bind(this);
+
+ /* We're using uncontrolled input otherwise it wont be possible to
+ * raise change events with a date value - each change will produce a date
+ * and make us reset the text input */
+ this.defaultValue = props.value
+ ? moment(props.value).format(CONST.DATE.MOMENT_FORMAT_STRING)
+ : '';
+ }
+
+ componentDidMount() {
+ // Adds nice native datepicker on web/desktop. Not possible to set this through props
+ this.inputRef.setAttribute('type', 'date');
+ this.inputRef.classList.add('expensify-datepicker');
+ }
+
+ /**
+ * Trigger the `onChange` handler when the user input has a complete date or is cleared
+ * @param {String} text
+ */
+ raiseDateChange(text) {
+ if (!text) {
+ this.props.onChange(null);
+ return;
+ }
+
+ const asMoment = moment(text);
+ if (asMoment.isValid()) {
+ const asDate = asMoment.toDate();
+ this.props.onChange(asDate);
+ }
+ }
+
+ /**
+ * Pops the datepicker up when we focus this field. This only works on mWeb
+ * On mWeb the user needs to tap on the field again in order to bring the datepicker. But our current styles
+ * don't make this very obvious. To avoid confusion we open the datepicker when the user focuses the field
+ */
+ showDatepicker() {
+ if (this.inputRef) {
+ this.inputRef.click();
+ }
+ }
+
+ render() {
+ const {
+ label,
+ placeholder,
+ hasError,
+ errorText,
+ translateX,
+ containerStyles,
+ disabled,
+ isSmallScreenWidth,
+ } = this.props;
+
+ return (
+ this.inputRef = input}
+ onFocus={this.showDatepicker}
+ label={label}
+ onChangeText={this.raiseDateChange}
+ defaultValue={this.defaultValue}
+ placeholder={placeholder}
+ hasError={hasError}
+ errorText={errorText}
+ containerStyles={containerStyles}
+ translateX={translateX}
+ disabled={disabled}
+ />
+ );
+ }
+}
+
+Datepicker.propTypes = datePickerPropTypes;
+Datepicker.defaultProps = defaultProps;
+
+export default withWindowDimensions(Datepicker);
diff --git a/src/components/DatePicker/styles.css b/src/components/DatePicker/styles.css
new file mode 100644
index 00000000000..e477dc4c845
--- /dev/null
+++ b/src/components/DatePicker/styles.css
@@ -0,0 +1,15 @@
+/**
+ * Using a .css file is a special case here, because it's impossible to target
+ * pseudo-elements from js code (e.g. ::-webkit)
+ * It also helps in cases like using the Datepicker in Storybook - the styles are imported
+ * with the component
+ */
+
+.expensify-datepicker::-webkit-calendar-picker-indicator {
+ position: absolute;
+ right: 12px;
+ top: 13px;
+}
+.expensify-datepicker::-webkit-date-and-time-value {
+ text-align: left;
+}
diff --git a/src/components/ExpensiTextInput/BaseExpensiTextInput.js b/src/components/ExpensiTextInput/BaseExpensiTextInput.js
index d6f3d4b1e53..04c92bf7f1e 100644
--- a/src/components/ExpensiTextInput/BaseExpensiTextInput.js
+++ b/src/components/ExpensiTextInput/BaseExpensiTextInput.js
@@ -22,19 +22,20 @@ class BaseExpensiTextInput extends Component {
constructor(props) {
super(props);
- const hasValue = props.value && props.value.length > 0;
+ this.value = props.value || props.defaultValue || '';
+ const activeLabel = props.forceActiveLabel || this.value.length > 0;
this.state = {
isFocused: false,
- labelTranslateY: new Animated.Value(hasValue ? ACTIVE_LABEL_TRANSLATE_Y : INACTIVE_LABEL_TRANSLATE_Y),
- labelTranslateX: new Animated.Value(hasValue
+ labelTranslateY: new Animated.Value(activeLabel ? ACTIVE_LABEL_TRANSLATE_Y : INACTIVE_LABEL_TRANSLATE_Y),
+ labelTranslateX: new Animated.Value(activeLabel
? ACTIVE_LABEL_TRANSLATE_X(props.translateX) : INACTIVE_LABEL_TRANSLATE_X),
- labelScale: new Animated.Value(hasValue ? ACTIVE_LABEL_SCALE : INACTIVE_LABEL_SCALE),
+ labelScale: new Animated.Value(activeLabel ? ACTIVE_LABEL_SCALE : INACTIVE_LABEL_SCALE),
};
this.input = null;
- this.value = hasValue ? props.value : '';
- this.isLabelActive = false;
+ this.isLabelActive = activeLabel;
+ this.onPress = this.onPress.bind(this);
this.onFocus = this.onFocus.bind(this);
this.onBlur = this.onBlur.bind(this);
this.setValue = this.setValue.bind(this);
@@ -47,14 +48,41 @@ class BaseExpensiTextInput extends Component {
}
}
- onFocus() {
- if (this.props.onFocus) { this.props.onFocus(); }
+ componentDidUpdate(prevProps) {
+ // activate or deactivate the label when value is changed programmatically from outside
+ if (prevProps.value !== this.props.value) {
+ this.value = this.props.value;
+
+ if (this.props.value) {
+ this.activateLabel();
+ } else if (!this.state.isFocused) {
+ this.deactivateLabel();
+ }
+ }
+ }
+
+ onPress(event) {
+ if (this.props.disabled) {
+ return;
+ }
+
+ if (this.props.onPress) {
+ this.props.onPress(event);
+ }
+
+ if (!event.isDefaultPrevented()) {
+ this.input.focus();
+ }
+ }
+
+ onFocus(event) {
+ if (this.props.onFocus) { this.props.onFocus(event); }
this.setState({isFocused: true});
this.activateLabel();
}
- onBlur() {
- if (this.props.onBlur) { this.props.onBlur(); }
+ onBlur(event) {
+ if (this.props.onBlur) { this.props.onBlur(event); }
this.setState({isFocused: false});
this.deactivateLabel();
}
@@ -83,7 +111,7 @@ class BaseExpensiTextInput extends Component {
}
deactivateLabel() {
- if (this.value.length === 0) {
+ if (!this.props.forceActiveLabel && this.value.length === 0) {
this.animateLabel(INACTIVE_LABEL_TRANSLATE_Y, INACTIVE_LABEL_TRANSLATE_X, INACTIVE_LABEL_SCALE);
this.isLabelActive = false;
}
@@ -134,11 +162,10 @@ class BaseExpensiTextInput extends Component {
...containerStyles,
]}
>
- this.input.focus()} focusable={false}>
+
diff --git a/src/components/ExpensiTextInput/ExpensiTextInputLabel/index.js b/src/components/ExpensiTextInput/ExpensiTextInputLabel/index.js
index b0ed11c58bb..955c0f6ba4a 100644
--- a/src/components/ExpensiTextInput/ExpensiTextInputLabel/index.js
+++ b/src/components/ExpensiTextInput/ExpensiTextInputLabel/index.js
@@ -19,6 +19,7 @@ const ExpensiTextInputLabel = ({
labelScale,
),
]}
+ pointerEvents="none"
>
{label}
diff --git a/src/components/ExpensiTextInput/baseExpensiTextInputPropTypes.js b/src/components/ExpensiTextInput/baseExpensiTextInputPropTypes.js
index b535a244c55..d7379279f76 100644
--- a/src/components/ExpensiTextInput/baseExpensiTextInputPropTypes.js
+++ b/src/components/ExpensiTextInput/baseExpensiTextInputPropTypes.js
@@ -5,7 +5,10 @@ const propTypes = {
label: PropTypes.string,
/** Input value */
- value: PropTypes.string.isRequired,
+ value: PropTypes.string,
+
+ /** Default value - used for non controlled inputs */
+ defaultValue: PropTypes.string,
/** Input value placeholder */
placeholder: PropTypes.string,
@@ -27,6 +30,8 @@ const propTypes = {
/** should ignore labels translate x? */
ignoreLabelTranslateX: PropTypes.bool,
+
+ forceActiveLabel: PropTypes.bool,
};
const defaultProps = {
@@ -38,6 +43,14 @@ const defaultProps = {
translateX: -22,
inputStyle: [],
ignoreLabelTranslateX: false,
+
+ /**
+ * To be able to function as either controlled or uncontrolled component we should not
+ * assign a default prop value for `value` or `defaultValue` props
+ */
+ value: undefined,
+ defaultValue: undefined,
+ forceActiveLabel: false,
};
export {propTypes, defaultProps};
diff --git a/src/components/FormAlertWithSubmitButton.js b/src/components/FormAlertWithSubmitButton.js
new file mode 100644
index 00000000000..dee67a5ef0a
--- /dev/null
+++ b/src/components/FormAlertWithSubmitButton.js
@@ -0,0 +1,113 @@
+import _ from 'underscore';
+import React from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import styles from '../styles/styles';
+import Icon from './Icon';
+import {Exclamation} from './Icon/Expensicons';
+import colors from '../styles/colors';
+import Button from './Button';
+import withLocalize, {withLocalizePropTypes} from './withLocalize';
+import TextLink from './TextLink';
+import Text from './Text';
+import RenderHTML from './RenderHTML';
+
+const propTypes = {
+ /** Whether to show the alert text */
+ isAlertVisible: PropTypes.bool.isRequired,
+
+ /** Submit function */
+ onSubmit: PropTypes.func.isRequired,
+
+ /** Text for the button */
+ buttonText: PropTypes.string.isRequired,
+
+ /** Callback fired when the "fix the errors" link is pressed */
+ onFixTheErrorsLinkPressed: PropTypes.func.isRequired,
+
+ /** Error message to display above button */
+ message: PropTypes.string,
+
+ /** Whether message is in html format */
+ isMessageHtml: PropTypes.bool,
+
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ message: '',
+ isMessageHtml: false,
+};
+
+const FormAlertWithSubmitButton = ({
+ isAlertVisible,
+ onSubmit,
+ buttonText,
+ translate,
+ onFixTheErrorsLinkPressed,
+ message,
+ isMessageHtml,
+}) => {
+ /**
+ * @returns {React.Component}
+ */
+ function getAlertPrompt() {
+ let error = '';
+
+ if (!_.isEmpty(message)) {
+ if (isMessageHtml) {
+ error = (
+ ${message}`} />
+ );
+ } else {
+ error = (
+ {message}
+ );
+ }
+ } else {
+ error = (
+ <>
+
+ {`${translate('common.please')} `}
+
+
+ {translate('common.fixTheErrors')}
+
+
+ {` ${translate('common.inTheFormBeforeContinuing')}.`}
+
+ >
+ );
+ }
+
+ return (
+
+ {error}
+
+ );
+ }
+
+ return (
+
+ {isAlertVisible && (
+
+
+ {getAlertPrompt()}
+
+ )}
+
+
+ );
+};
+
+FormAlertWithSubmitButton.propTypes = propTypes;
+FormAlertWithSubmitButton.defaultProps = defaultProps;
+export default withLocalize(FormAlertWithSubmitButton);
diff --git a/src/components/FullscreenLoadingIndicator.js b/src/components/FullscreenLoadingIndicator.js
index 6a203a9690b..e8efeb75474 100644
--- a/src/components/FullscreenLoadingIndicator.js
+++ b/src/components/FullscreenLoadingIndicator.js
@@ -1,16 +1,22 @@
+import _ from 'underscore';
import React from 'react';
import PropTypes from 'prop-types';
import {ActivityIndicator, StyleSheet, View} from 'react-native';
import styles from '../styles/styles';
import themeColors from '../styles/themes/default';
+import stylePropTypes from '../styles/stylePropTypes';
const propTypes = {
/** Controls whether the loader is mounted and displayed */
visible: PropTypes.bool,
+
+ /** Additional style props */
+ style: stylePropTypes,
};
const defaultProps = {
visible: true,
+ style: [],
};
/**
@@ -18,13 +24,21 @@ const defaultProps = {
*
* @returns {JSX.Element}
*/
-const FullScreenLoadingIndicator = ({visible}) => visible && (
-
-
-
-);
+const FullScreenLoadingIndicator = ({visible, style}) => {
+ if (!visible) {
+ return null;
+ }
+
+ const additionalStyles = _.isArray(style) ? style : [style];
+ return (
+
+
+
+ );
+};
FullScreenLoadingIndicator.propTypes = propTypes;
FullScreenLoadingIndicator.defaultProps = defaultProps;
+FullScreenLoadingIndicator.displayName = 'FullScreenLoadingIndicator';
export default FullScreenLoadingIndicator;
diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
index b68762e7aa1..d0b8a3d6723 100755
--- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
+++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
@@ -1,7 +1,7 @@
/* eslint-disable react/prop-types */
import _ from 'underscore';
import React, {useMemo} from 'react';
-import {TouchableOpacity} from 'react-native';
+import {TouchableOpacity, Linking} from 'react-native';
import {
TRenderEngineProvider,
RenderHTMLConfigProvider,
@@ -66,6 +66,23 @@ function computeEmbeddedMaxWidth(tagName, contentWidth) {
return contentWidth;
}
+/**
+ * Check if there is an ancestor node with name 'comment'.
+ * Finding node with name 'comment' flags that we are rendering a comment.
+ * @param {TNode} tnode
+ * @returns {Boolean}
+ */
+function isInsideComment(tnode) {
+ let currentNode = tnode;
+ while (currentNode.parent) {
+ if (currentNode.domNode.name === 'comment') {
+ return true;
+ }
+ currentNode = currentNode.parent;
+ }
+ return false;
+}
+
function AnchorRenderer({tnode, key, style}) {
const htmlAttribs = tnode.attributes;
@@ -89,6 +106,22 @@ function AnchorRenderer({tnode, key, style}) {
);
}
+ if (!isInsideComment(tnode)) {
+ // This is not a comment from a chat, the AnchorForCommentsOnly uses a Pressable to create a context menu on right click.
+ // We don't have this behaviour in other links in NewDot
+ // TODO: We should use TextLink, but I'm leaving it as Text for now because TextLink breaks the alignment in Android.
+ return (
+ {
+ Linking.openURL(htmlAttribs.href);
+ }}
+ >
+
+
+ );
+ }
+
return (
(
+
+
-
- {props.title}
-
+
+
+ {props.title}
+
+ {/* If there's no subtitle then display a fragment to avoid an empty space which moves the main title */}
+ {props.subtitle ? {props.subtitle} : <>> }
+
{props.shouldShowEnvironmentBadge && (
)}
diff --git a/src/components/HeaderWithCloseButton.js b/src/components/HeaderWithCloseButton.js
index d29a6e725df..6c04c74098f 100755
--- a/src/components/HeaderWithCloseButton.js
+++ b/src/components/HeaderWithCloseButton.js
@@ -40,6 +40,12 @@ const propTypes = {
/** The task ID to associate with the call button, if we show it */
inboxCallTaskID: PropTypes.string,
+ /** Data to display a step counter in the header */
+ stepCounter: PropTypes.shape({
+ step: PropTypes.number,
+ total: PropTypes.number,
+ }),
+
...withLocalizePropTypes,
};
@@ -53,6 +59,7 @@ const defaultProps = {
shouldShowDownloadButton: false,
shouldShowInboxCallButton: false,
inboxCallTaskID: '',
+ stepCounter: null,
};
const HeaderWithCloseButton = props => (
@@ -76,7 +83,10 @@ const HeaderWithCloseButton = props => (
)}
-
+
{
props.shouldShowDownloadButton && (
diff --git a/src/components/IOUConfirmationList.js b/src/components/IOUConfirmationList.js
index b3e946a3f34..8c98d99a0b5 100755
--- a/src/components/IOUConfirmationList.js
+++ b/src/components/IOUConfirmationList.js
@@ -13,7 +13,6 @@ import {
getIOUConfirmationOptionsFromParticipants,
} from '../libs/OptionsListUtils';
import OptionsList from './OptionsList';
-import Button from './Button';
import ONYXKEYS from '../ONYXKEYS';
import withLocalize, {withLocalizePropTypes} from './withLocalize';
import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions';
@@ -21,6 +20,14 @@ import compose from '../libs/compose';
import FixedFooter from './FixedFooter';
import ExpensiTextInput from './ExpensiTextInput';
import CONST from '../CONST';
+import ButtonWithMenu from './ButtonWithMenu';
+import {
+ Cash, Wallet, Venmo, PayPal,
+} from './Icon/Expensicons';
+import Permissions from '../libs/Permissions';
+import isAppInstalled from '../libs/isAppInstalled';
+import {isValidUSPhone} from '../libs/ValidationUtils';
+import makeCancellablePromise from '../libs/MakeCancellablePromise';
const propTypes = {
/** Callback to inform parent modal of success */
@@ -38,6 +45,9 @@ const propTypes = {
/** IOU amount */
iouAmount: PropTypes.string.isRequired,
+ /** IOU type */
+ iouType: PropTypes.string,
+
// Selected participants from IOUModal with login
participants: PropTypes.arrayOf(PropTypes.shape({
login: PropTypes.string.isRequired,
@@ -51,6 +61,8 @@ const propTypes = {
isUnread: PropTypes.bool,
reportID: PropTypes.number,
participantsList: PropTypes.arrayOf(PropTypes.object),
+ payPalMeAddress: PropTypes.string,
+ phoneNumber: PropTypes.string,
})).isRequired,
...windowDimensionsPropTypes,
@@ -102,21 +114,75 @@ const defaultProps = {
comment: '',
network: {},
myPersonalDetails: {},
+ iouType: CONST.IOU.IOU_TYPE.REQUEST,
};
class IOUConfirmationList extends Component {
constructor(props) {
super(props);
- this.toggleOption = this.toggleOption.bind(this);
-
const formattedParticipants = _.map(this.getParticipantsWithAmount(this.props.participants), participant => ({
...participant, selected: true,
}));
+ // Add the button options to payment menu
+ const confirmationButtonOptions = [];
+ let defaultButtonOption = {
+ text: this.props.translate(this.props.hasMultipleParticipants ? 'iou.split' : 'iou.request', {
+ amount: this.props.numberFormat(
+ this.props.iouAmount,
+ {style: 'currency', currency: this.props.iou.selectedCurrencyCode},
+ ),
+ }),
+ };
+ if (this.props.iouType === CONST.IOU.IOU_TYPE.SEND && this.props.participants.length === 1 && Permissions.canUseIOUSend(this.props.betas)) {
+ // Add the Expensify Wallet option if available and make it the first option
+ if (this.props.localCurrencyCode === CONST.CURRENCY.USD && Permissions.canUsePayWithExpensify(this.props.betas) && Permissions.canUseWallet(this.props.betas)) {
+ confirmationButtonOptions.push({text: this.props.translate('iou.settleExpensify'), icon: Wallet});
+ }
+
+ // Add PayPal option
+ if (this.props.participants[0].payPalMeAddress) {
+ confirmationButtonOptions.push({text: this.props.translate('iou.settlePaypalMe'), icon: PayPal});
+ }
+ defaultButtonOption = {text: this.props.translate('iou.settleElsewhere'), icon: Cash};
+ }
+ confirmationButtonOptions.push(defaultButtonOption);
+
+ this.checkVenmoAvailabilityPromise = null;
+
this.state = {
+ confirmationButtonOptions,
participants: formattedParticipants,
};
+
+ this.toggleOption = this.toggleOption.bind(this);
+ this.onPress = this.onPress.bind(this);
+ }
+
+ componentDidMount() {
+ // Only add the Venmo option if we're sending a payment
+ if (this.props.iouType === CONST.IOU.IOU_TYPE.SEND) {
+ this.addVenmoPaymentOptionToMenu();
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.checkVenmoAvailabilityPromise) {
+ this.checkVenmoAvailabilityPromise.cancel();
+ this.checkVenmoAvailabilityPromise = null;
+ }
+ }
+
+ /**
+ * When confirmation button is clicked
+ */
+ onPress() {
+ if (this.props.iouType === CONST.IOU.IOU_TYPE.SEND) {
+ this.props.onConfirm();
+ } else {
+ this.props.onConfirm(this.getSplits());
+ }
}
/**
@@ -258,6 +324,30 @@ class IOUConfirmationList extends Component {
];
}
+ /**
+ * Adds Venmo, if available, as the second option in the menu of payment options
+ */
+ addVenmoPaymentOptionToMenu() {
+ // Add Venmo option
+ if (this.props.localCurrencyCode === CONST.CURRENCY.USD && this.state.participants[0].phoneNumber && isValidUSPhone(this.state.participants[0].phoneNumber)) {
+ this.checkVenmoAvailabilityPromise = makeCancellablePromise(isAppInstalled('venmo'));
+ this.checkVenmoAvailabilityPromise
+ .promise
+ .then((isVenmoInstalled) => {
+ if (!isVenmoInstalled) {
+ return;
+ }
+
+ this.setState(prevState => ({
+ confirmationButtonOptions: [...prevState.confirmationButtonOptions.slice(0, 1),
+ {text: this.props.translate('iou.settleVenmo'), icon: Venmo},
+ ...prevState.confirmationButtonOptions.slice(1),
+ ],
+ }));
+ });
+ }
+ }
+
/**
* Calculates the amount per user given a list of participants
* @param {Array} participants
@@ -306,14 +396,6 @@ class IOUConfirmationList extends Component {
}
render() {
- const buttonText = this.props.translate(
- this.props.hasMultipleParticipants ? 'iou.split' : 'iou.request', {
- amount: this.props.numberFormat(
- this.props.iouAmount,
- {style: 'currency', currency: this.props.iou.selectedCurrencyCode},
- ),
- },
- );
const hoverStyle = this.props.hasMultipleParticipants ? styles.hoveredComponentBG : {};
const toggleOption = this.props.hasMultipleParticipants ? this.toggleOption : undefined;
const selectedParticipants = this.getSelectedParticipants();
@@ -349,14 +431,12 @@ class IOUConfirmationList extends Component {
{this.props.translate('session.offlineMessage')}
)}
-
@@ -155,7 +110,7 @@ const MenuItem = ({
style={[
styles.popoverMenuText,
styles.ml3,
- (disabled ? styles.disabledText : undefined),
+ (interactive && disabled ? styles.disabledText : undefined),
]}
numberOfLines={1}
>
@@ -183,7 +138,7 @@ const MenuItem = ({
)}
diff --git a/src/components/MenuItemList.js b/src/components/MenuItemList.js
new file mode 100644
index 00000000000..3ffa6aa9806
--- /dev/null
+++ b/src/components/MenuItemList.js
@@ -0,0 +1,31 @@
+import React from 'react';
+import _ from 'underscore';
+import PropTypes from 'prop-types';
+import MenuItem from './MenuItem';
+import menuItemPropTypes from './menuItemPropTypes';
+
+const propTypes = {
+ /** An array of props that are pass to individual MenuItem components */
+ menuItems: PropTypes.arrayOf(PropTypes.shape(menuItemPropTypes)),
+};
+const defaultProps = {
+ menuItems: [],
+};
+
+const MenuItemList = ({menuItems}) => (
+ <>
+ {_.map(menuItems, menuItemProps => (
+
+ ))}
+ >
+);
+
+MenuItemList.displayName = 'MenuItemList';
+MenuItemList.propTypes = propTypes;
+MenuItemList.defaultProps = defaultProps;
+
+export default MenuItemList;
diff --git a/src/components/Onfido/index.js b/src/components/Onfido/index.js
index 5c1f01d4118..b97e4d1f677 100644
--- a/src/components/Onfido/index.js
+++ b/src/components/Onfido/index.js
@@ -1,10 +1,14 @@
import './index.css';
+import lodashGet from 'lodash/get';
import React from 'react';
import * as OnfidoSDK from 'onfido-sdk-ui';
import withLocalize, {withLocalizePropTypes} from '../withLocalize';
import onfidoPropTypes from './onfidoPropTypes';
import CONST from '../../CONST';
-import Growl from '../../libs/Growl';
+import variables from '../../styles/variables';
+import colors from '../../styles/colors';
+import fontWeightBold from '../../styles/fontWeight/bold';
+import fontFamily from '../../styles/fontFamily';
const propTypes = {
...withLocalizePropTypes,
@@ -16,6 +20,38 @@ class Onfido extends React.Component {
this.onfidoOut = OnfidoSDK.init({
token: this.props.sdkToken,
containerId: CONST.ONFIDO.CONTAINER_ID,
+ customUI: {
+ fontFamilyTitle: `${fontFamily.GTA}, -apple-system, serif`,
+ fontFamilySubtitle: `${fontFamily.GTA}, -apple-system, serif`,
+ fontFamilyBody: `${fontFamily.GTA}, -apple-system, serif`,
+ fontSizeTitle: `${variables.fontSizeLarge}px`,
+ fontWeightTitle: fontWeightBold,
+ fontWeightSubtitle: 400,
+ fontSizeSubtitle: `${variables.fontSizeNormal}px`,
+ colorContentTitle: colors.dark,
+ colorContentSubtitle: colors.dark,
+ colorContentBody: colors.dark,
+ borderRadiusButton: `${variables.componentBorderRadius}px`,
+ colorBackgroundSurfaceModal: colors.white,
+ colorBorderDocTypeButton: colors.gray2,
+ colorBorderDocTypeButtonHover: colors.blue,
+ colorBackgroundButtonPrimary: colors.green,
+ colorBackgroundButtonPrimaryHover: colors.greenHover,
+ colorBackgroundButtonPrimaryActive: colors.greenHover,
+ colorBorderButtonPrimary: colors.green,
+ colorContentButtonSecondaryText: colors.dark,
+ colorBackgroundButtonSecondary: colors.gray2,
+ colorBackgroundButtonSecondaryHover: colors.gray3,
+ colorBackgroundButtonSecondaryActive: colors.gray3,
+ colorBorderButtonSecondary: colors.gray2,
+ colorBackgroundIcon: colors.white,
+ colorContentLinkTextHover: colors.white,
+ colorBorderLinkUnderline: colors.blue,
+ colorBackgroundLinkHover: colors.blue,
+ colorBackgroundLinkActive: colors.blue,
+ authAccentColor: colors.blue,
+ colorBackgroundInfoPill: colors.blue,
+ },
steps: [
{
type: CONST.ONFIDO.TYPE.DOCUMENT,
@@ -24,6 +60,9 @@ class Onfido extends React.Component {
forceCrossDevice: true,
showCountrySelection: false,
documentTypes: {
+ driving_licence: {
+ country: null,
+ },
national_identity_card: {
country: null,
},
@@ -45,9 +84,9 @@ class Onfido extends React.Component {
smsNumberCountryCode: CONST.ONFIDO.SMS_NUMBER_COUNTRY_CODE.US,
showCountrySelection: false,
onComplete: this.props.onSuccess,
- onError: () => {
- this.props.onUserExit();
- Growl.error(this.props.translate('onfidoStep.genericError'));
+ onError: (error) => {
+ const errorMessage = lodashGet(error, 'message', CONST.ERROR.UNKNOWN_ERROR);
+ this.props.onError(errorMessage);
},
onUserExit: this.props.onUserExit,
onModalRequestClose: () => {},
diff --git a/src/components/Onfido/index.native.js b/src/components/Onfido/index.native.js
index 43970fb0a7f..a84098c70f5 100644
--- a/src/components/Onfido/index.native.js
+++ b/src/components/Onfido/index.native.js
@@ -1,3 +1,5 @@
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
import React from 'react';
import {
Onfido as OnfidoSDK,
@@ -8,7 +10,6 @@ import {
import onfidoPropTypes from './onfidoPropTypes';
import CONST from '../../CONST';
import withLocalize, {withLocalizePropTypes} from '../withLocalize';
-import Growl from '../../libs/Growl';
const propTypes = {
...withLocalizePropTypes,
@@ -32,10 +33,16 @@ class Onfido extends React.Component {
})
.then(this.props.onSuccess)
.catch((error) => {
- if (error.message === CONST.ONFIDO.ERROR.USER_CANCELLED) {
+ const errorMessage = lodashGet(error, 'message', CONST.ERROR.UNKNOWN_ERROR);
+
+ // If the user cancels the Onfido flow we won't log this error as it's normal. In the React Native SDK the user exiting the flow will trigger this error which we can use as
+ // our "user exited the flow" callback. On web, this event has it's own callback passed as a config so we don't need to bother with this there.
+ if (_.contains([CONST.ONFIDO.ERROR.USER_CANCELLED, CONST.ONFIDO.ERROR.USER_TAPPED_BACK], errorMessage)) {
this.props.onUserExit();
- Growl.error(this.props.translate('onfidoStep.genericError'));
+ return;
}
+
+ this.props.onError(errorMessage);
});
}
diff --git a/src/components/Onfido/onfidoPropTypes.js b/src/components/Onfido/onfidoPropTypes.js
index a45b124c139..ff0023c7005 100644
--- a/src/components/Onfido/onfidoPropTypes.js
+++ b/src/components/Onfido/onfidoPropTypes.js
@@ -9,4 +9,7 @@ export default {
/** Called when the user is totally done with Onfido */
onSuccess: PropTypes.func.isRequired,
+
+ /** Called when Onfido throws an error */
+ onError: PropTypes.func.isRequired,
};
diff --git a/src/components/PDFView/index.js b/src/components/PDFView/index.js
index b6a0116bb2d..6beb9c10c00 100644
--- a/src/components/PDFView/index.js
+++ b/src/components/PDFView/index.js
@@ -5,6 +5,7 @@ import {Document, Page} from 'react-pdf/dist/esm/entry.webpack';
import styles from '../../styles/styles';
import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions';
import variables from '../../styles/variables';
+import FullScreenLoadingIndicator from '../FullscreenLoadingIndicator';
const propTypes = {
/** URL to full-sized image */
@@ -53,6 +54,7 @@ class PDFView extends PureComponent {
style={[styles.PDFView, this.props.style]}
>
}
file={this.props.sourceURL}
options={{
cMapUrl: 'cmaps/',
diff --git a/src/components/PDFView/index.native.js b/src/components/PDFView/index.native.js
index ffe41b45ccb..962bf4e5224 100644
--- a/src/components/PDFView/index.native.js
+++ b/src/components/PDFView/index.native.js
@@ -4,6 +4,7 @@ import {View} from 'react-native';
import PDF from 'react-native-pdf';
import styles, {getWidthAndHeightStyle} from '../../styles/styles';
import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions';
+import FullScreenLoadingIndicator from '../FullscreenLoadingIndicator';
const propTypes = {
/** URL to full-sized image */
@@ -31,6 +32,7 @@ const defaultProps = {
const PDFView = props => (
}
source={{uri: props.sourceURL}}
style={[
styles.imageModalPDF,
diff --git a/src/components/ReimbursementAccountLoadingIndicator.js b/src/components/ReimbursementAccountLoadingIndicator.js
new file mode 100644
index 00000000000..c949cbecfba
--- /dev/null
+++ b/src/components/ReimbursementAccountLoadingIndicator.js
@@ -0,0 +1,49 @@
+import React from 'react';
+import {Image, StyleSheet, View} from 'react-native';
+import PropTypes from 'prop-types';
+import styles from '../styles/styles';
+import CONST from '../CONST';
+import withLocalize, {withLocalizePropTypes} from './withLocalize';
+import Text from './Text';
+import HeaderWithCloseButton from './HeaderWithCloseButton';
+import Navigation from '../libs/Navigation/Navigation';
+import ScreenWrapper from './ScreenWrapper';
+import FullScreenLoadingIndicator from './FullscreenLoadingIndicator';
+
+const propTypes = {
+ /** Whether the user is submitting verifications data */
+ isSubmittingVerificationsData: PropTypes.bool.isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const ReimbursementAccountLoadingIndicator = ({translate, isSubmittingVerificationsData}) => (
+
+
+ {isSubmittingVerificationsData ? (
+
+
+
+
+ {translate('reimbursementAccountLoadingAnimation.explanationLine')}
+
+
+
+ ) : (
+
+ )}
+
+);
+
+ReimbursementAccountLoadingIndicator.propTypes = propTypes;
+ReimbursementAccountLoadingIndicator.displayName = 'ReimbursementAccountLoadingIndicator';
+
+export default withLocalize(ReimbursementAccountLoadingIndicator);
diff --git a/src/components/StatePicker.js b/src/components/StatePicker.js
index 3e09243a37e..0d25f7ae969 100644
--- a/src/components/StatePicker.js
+++ b/src/components/StatePicker.js
@@ -11,6 +11,9 @@ const STATES = _.map(CONST.STATES, ({stateISO}) => ({
}));
const propTypes = {
+ /** The label for the field */
+ label: PropTypes.string,
+
/** A callback method that is called when the value changes and it received the selected value as an argument */
onChange: PropTypes.func.isRequired,
@@ -21,6 +24,7 @@ const propTypes = {
};
const defaultProps = {
+ label: '',
value: '',
};
@@ -30,7 +34,7 @@ const StatePicker = props => (
items={STATES}
onChange={props.onChange}
value={props.value}
- label={props.translate('common.state')}
+ label={props.label || props.translate('common.state')}
hasError={props.hasError}
errorText={props.errorText}
/>
diff --git a/src/components/Text.js b/src/components/Text.js
index 090e60fef72..2aebe5b69ce 100644
--- a/src/components/Text.js
+++ b/src/components/Text.js
@@ -50,7 +50,6 @@ const Text = React.forwardRef(({
...finalStyles,
...s,
}), {});
-
const componentStyle = {
color,
fontSize,
@@ -59,7 +58,7 @@ const Text = React.forwardRef(({
...mergedStyles,
};
- if (componentStyle.fontSize === variables.fontSizeNormal) {
+ if (!componentStyle.lineHeight && componentStyle.fontSize === variables.fontSizeNormal) {
componentStyle.lineHeight = variables.fontSizeNormalHeight;
}
diff --git a/src/components/TextLink.js b/src/components/TextLink.js
index 4403c55715c..e8947f21434 100644
--- a/src/components/TextLink.js
+++ b/src/components/TextLink.js
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import {Pressable, Linking} from 'react-native';
import Text from './Text';
import styles from '../styles/styles';
+import stylePropTypes from '../styles/stylePropTypes';
const propTypes = {
/** Link to open in new tab */
@@ -17,7 +18,7 @@ const propTypes = {
]).isRequired,
/** Additional style props */
- style: PropTypes.oneOfType([PropTypes.object, PropTypes.arrayOf(PropTypes.object)]),
+ style: stylePropTypes,
/** Overwrites the default link behavior with a custom callback */
onPress: PropTypes.func,
diff --git a/src/components/UnorderedList.js b/src/components/UnorderedList.js
new file mode 100644
index 00000000000..9c2ee04fc8f
--- /dev/null
+++ b/src/components/UnorderedList.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import _ from 'underscore';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import Text from './Text';
+import styles from '../styles/styles';
+
+const propTypes = {
+ /** An array of strings to display as an unordered list */
+ items: PropTypes.arrayOf(PropTypes.string),
+};
+const defaultProps = {
+ items: [],
+};
+
+const UnorderedList = ({items}) => (
+ <>
+ {_.map(items, itemText => (
+
+ {'\u2022'}
+ {itemText}
+
+ ))}
+ >
+);
+
+UnorderedList.displayName = 'UnorderedList';
+UnorderedList.propTypes = propTypes;
+UnorderedList.defaultProps = defaultProps;
+
+export default UnorderedList;
diff --git a/src/components/VBALoadingIndicator.js b/src/components/VBALoadingIndicator.js
deleted file mode 100644
index 8534e91f2b5..00000000000
--- a/src/components/VBALoadingIndicator.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import React from 'react';
-import {Image, StyleSheet, View} from 'react-native';
-import styles from '../styles/styles';
-import CONST from '../CONST';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-import Text from './Text';
-import HeaderWithCloseButton from './HeaderWithCloseButton';
-import Navigation from '../libs/Navigation/Navigation';
-import ScreenWrapper from './ScreenWrapper';
-
-const propTypes = {
- ...withLocalizePropTypes,
-};
-
-const VBALoadingIndicator = ({translate}) => (
-
-
-
-
-
-
- {translate('vbaLoadingAnimation.explanationLine')}
-
-
-
-
-);
-
-VBALoadingIndicator.propTypes = propTypes;
-
-export default withLocalize(VBALoadingIndicator);
diff --git a/src/components/bankAccountPropTypes.js b/src/components/bankAccountPropTypes.js
new file mode 100644
index 00000000000..3331a617cbc
--- /dev/null
+++ b/src/components/bankAccountPropTypes.js
@@ -0,0 +1,15 @@
+import PropTypes from 'prop-types';
+
+export default PropTypes.shape({
+ /** The name of the institution (bank of america, etc */
+ addressName: PropTypes.string,
+
+ /** The masked bank account number */
+ accountNumber: PropTypes.string,
+
+ /** The bankAccountID in the bankAccounts db */
+ bankAccountID: PropTypes.number,
+
+ /** The bank account type */
+ type: PropTypes.string,
+});
diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js
new file mode 100644
index 00000000000..8a00a360d91
--- /dev/null
+++ b/src/components/menuItemPropTypes.js
@@ -0,0 +1,62 @@
+import PropTypes from 'prop-types';
+import CONST from '../CONST';
+import stylePropTypes from '../styles/stylePropTypes';
+
+const propTypes = {
+ /** Text to be shown as badge near the right end. */
+ badgeText: PropTypes.string,
+
+ /** Any additional styles to apply */
+ // eslint-disable-next-line react/forbid-prop-types
+ wrapperStyle: stylePropTypes,
+
+ /** Function to fire when component is pressed */
+ onPress: PropTypes.func,
+
+ /** Icon to display on the left side of component */
+ icon: PropTypes.oneOfType([PropTypes.elementType, PropTypes.string]),
+
+ /** Icon Width */
+ iconWidth: PropTypes.number,
+
+ /** Icon Height */
+ iconHeight: PropTypes.number,
+
+ /** Text to display for the item */
+ title: PropTypes.string.isRequired,
+
+ /** Boolean whether to display the right icon */
+ shouldShowRightIcon: PropTypes.bool,
+
+ /** A boolean flag that gives the icon a green fill if true */
+ success: PropTypes.bool,
+
+ /** Overrides the icon for shouldShowRightIcon */
+ iconRight: PropTypes.elementType,
+
+ /** A description text to show under the title */
+ description: PropTypes.string,
+
+ /** Any additional styles to pass to the icon container. */
+ iconStyles: PropTypes.arrayOf(PropTypes.object),
+
+ /** The fill color to pass into the icon. */
+ iconFill: PropTypes.string,
+
+ /** Whether item is focused or active */
+ focused: PropTypes.bool,
+
+ /** Should we disable this menu item? */
+ disabled: PropTypes.bool,
+
+ /** A right-aligned subtitle for this menu option */
+ subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+
+ /** Flag to choose between avatar image or an icon */
+ iconType: PropTypes.oneOf([CONST.ICON_TYPE_AVATAR, CONST.ICON_TYPE_ICON]),
+
+ /** Whether the menu item should be interactive at all */
+ interactive: PropTypes.bool,
+};
+
+export default propTypes;
diff --git a/src/languages/en.js b/src/languages/en.js
index f250f221c5d..60f45635c34 100755
--- a/src/languages/en.js
+++ b/src/languages/en.js
@@ -26,6 +26,7 @@ export default {
continue: 'Continue',
firstName: 'First name',
lastName: 'Last name',
+ phone: 'Phone',
phoneNumber: 'Phone number',
email: 'Email',
and: 'and',
@@ -44,21 +45,20 @@ export default {
saveAndContinue: 'Save & continue',
settings: 'Settings',
termsOfService: 'Terms of service',
- people: 'People',
+ members: 'Members',
invite: 'Invite',
here: 'here',
dob: 'Date of birth',
ssnLast4: 'Last 4 digits of SSN',
personalAddress: 'Personal address',
companyAddress: 'Company address',
- noPO: '(PO boxes and mail drop addresses are NOT allowed)',
+ noPO: 'PO boxes and mail drop addresses are not allowed',
city: 'City',
state: 'State',
zip: 'Zip code',
isRequiredField: 'is a required field',
whatThis: 'What\'s this?',
iAcceptThe: 'I accept the ',
- passwordCannotBeBlank: 'Password cannot be blank',
remove: 'Remove',
admin: 'Admin',
dateFormat: 'YYYY-MM-DD',
@@ -78,6 +78,12 @@ export default {
},
please: 'Please',
contactUs: 'contact us',
+ pleaseEnterEmailOrPhoneNumber: 'Please enter an email or phone number',
+ fixTheErrors: 'fix the errors',
+ inTheFormBeforeContinuing: 'in the form before continuing',
+ confirm: 'Confirm',
+ reset: 'Reset',
+ done: 'Done',
},
attachmentPicker: {
cameraPermissionRequired: 'Camera permission required',
@@ -199,7 +205,6 @@ export default {
always: 'Always',
daily: 'Daily',
mute: 'Mute',
- members: 'Members',
},
loginField: {
addYourPhoneToSettleViaVenmo: 'Add your phone number to settle up via Venmo.',
@@ -278,6 +283,31 @@ export default {
editPayPalAccount: 'Update PayPal account',
growlMessageOnSave: 'Your PayPal username was successfully added',
},
+ addDebitCardPage: {
+ addADebitCard: 'Add a Debit Card',
+ nameOnCard: 'Name on Card',
+ debitCardNumber: 'Debit Card Number',
+ expiration: 'Expiration',
+ expirationDate: 'MM/YYYY',
+ cvv: 'CVV',
+ billingAddress: 'Billing Address',
+ streetAddress: 'Street Address',
+ cityName: 'City Name',
+ expensifyTermsOfService: 'Expensify Terms Of Service',
+ growlMessageOnSave: 'Your debit card was successfully added',
+ error: {
+ invalidName: 'Please add a valid name',
+ zipCode: 'Please enter a valid zip code',
+ debitCardNumber: 'Please enter a valid debit card number',
+ expirationDate: 'Please enter a valid expiration date',
+ securityCode: 'Please enter a valid security code',
+ address: 'Please enter a valid billing address',
+ addressState: 'Please select a state',
+ addressCity: 'Please enter a city',
+ acceptedTerms: 'You must accept the Terms of Service to continue',
+ genericFailureMessage: 'An error occurred while adding your card, please try again',
+ },
+ },
paymentsPage: {
paymentMethodsTitle: 'Payment methods',
},
@@ -285,7 +315,7 @@ export default {
addPaymentMethod: 'Add payment method',
accountLastFour: 'Account ending in',
cardLastFour: 'Card ending in',
- addFirstPaymentMethod: 'Add a payment method to send and receive payments directly in the app',
+ addFirstPaymentMethod: 'Add a payment method to send and receive payments directly in the app.',
},
preferencesPage: {
mostRecent: 'Most recent',
@@ -341,7 +371,6 @@ export default {
},
},
loginForm: {
- pleaseEnterEmailOrPhoneNumber: 'Please enter an email or phone number',
phoneOrEmail: 'Phone or email',
error: {
invalidFormatLogin: 'The email or phone number entered is invalid. Please fix the format and try again.',
@@ -349,8 +378,10 @@ export default {
},
resendValidationForm: {
linkHasBeenResent: 'Link has been re-sent',
- weSentYouMagicSignInLink: ({loginType}) => `We've sent a magic sign in link to your ${loginType}.`,
+ weSentYouMagicSignInLink: ({login}) => `We've sent a magic sign in link to ${login}. Check your Inbox and your Spam folder and wait 5-10 minutes before trying again.`,
resendLink: 'Resend link',
+ unvalidatedAccount: 'This account exists but isn\'t validated, please check your inbox for your magic link.',
+ newAccount: ({login, loginType}) => `Welcome ${login}, it's always great to see a new face around here! Please check your ${loginType} for a magic link to validate your account.`,
},
detailsPage: {
localTime: 'Local time',
@@ -373,19 +404,21 @@ export default {
passwordNotSet: 'We were unable to set your new password correctly.',
accountNotValidated: 'We were unable to validate your account. The validation code may have expired.',
},
+ stepCounter: ({step, total}) => `Step ${step} of ${total}`,
bankAccount: {
accountNumber: 'Account number',
routingNumber: 'Routing number',
addBankAccount: 'Add bank account',
chooseAnAccount: 'Choose an account',
- logIntoYourBank: 'Log into your bank',
+ connectOnlineWithPlaid: 'Connect online with Plaid',
connectManually: 'Connect manually',
yourDataIsSecure: 'Your data is secure',
- toGetStarted: 'To get started with the Expensify Card, you first need to add a bank account.',
+ toGetStarted: 'Add a bank account and issue corporate cards, reimburse expenses, collect invoice payments, and pay bills, all from one place.',
plaidBodyCopy: 'Give your employees an easier way to pay - and get paid back - for company expenses.',
checkHelpLine: 'Your routing number and account number can be found on a check for the account.',
+ validateAccountError: 'In order to finish setting up your bank account, you must validate your account. Please check your email to validate your account, and return here to finish up!',
hasPhoneLoginError: 'To add a verified bank account please ensure your primary login is a valid email and try again. You can add your phone number as a secondary login.',
- hasBeenThrottledError: ({fromNow}) => `For security reasons, we're taking a break from bank account setup so you can double-check your company information. Please try again ${fromNow}. Sorry!`,
+ hasBeenThrottledError: 'There was an error adding your bank account. Please wait a few minutes and try again.',
buttonConfirm: 'Got it',
error: {
noBankAccountAvailable: 'Sorry, no bank account is available',
@@ -395,10 +428,10 @@ export default {
phoneNumber: 'Please enter a valid phone number',
companyName: 'Please enter a valid legal business name',
addressCity: 'Please enter a valid city',
- addressStreet: 'Please enter a valid address street that is not a PO Box',
+ addressStreet: 'Please enter a valid street address that is not a PO Box',
addressState: 'Please select a valid state',
- incorporationDate: 'Please enter a valid incorporation date',
- incorporationState: 'Please enter a valid Incorporation State',
+ incorporationDate: 'Please enter a valid date',
+ incorporationState: 'Please enter a valid state',
industryCode: 'Please enter a valid industry classification code. Must be 6 digits.',
restrictedBusiness: 'Please confirm company is not on the list of restricted businesses',
routingNumber: 'Please enter a valid Routing Number',
@@ -407,13 +440,12 @@ export default {
tooManyAttempts: 'Due to a high number of login attempts, this option has been temporarily disabled for 24 hours. Please try again later or manually enter details instead.',
address: 'Please enter a valid address',
dob: 'Please enter a valid date of birth',
- age: 'Requestors must be over 18 years old',
+ age: 'Must be over 18 years old',
ssnLast4: 'Please enter valid last 4 digits of SSN',
firstName: 'Please enter valid first name',
lastName: 'Please enter valid last name',
noDefaultDepositAccountOrDebitCardAvailable: 'Please add a default deposit bank account or debit card',
- fixTheErrors: 'fix the errors',
- inTheFormBeforeContinuing: 'in the form before continuing',
+ validationAmounts: 'The validation amounts you entered are incorrect. Please double-check your bank statement and try again.',
},
},
addPersonalBankAccountPage: {
@@ -527,22 +559,23 @@ export default {
},
companyStep: {
headerTitle: 'Company information',
- subtitle: 'Provide more information about your company.',
+ subtitle: 'Almost done! For security purposes, we need to confirm some information:',
legalBusinessName: 'Legal business name',
companyWebsite: 'Company website',
taxIDNumber: 'Tax ID number',
+ taxIDNumberPlaceholder: '9 digits, no hyphens',
companyType: 'Company type',
incorporationDate: 'Incorporation date',
+ incorporationState: 'Incorporation state',
industryClassificationCode: 'Industry classification code',
confirmCompanyIsNot: 'I confirm that this company is not on the',
listOfRestrictedBusinesses: 'list of restricted businesses',
incorporationDatePlaceholder: 'Start date (yyyy-mm-dd)',
- companyPhonePlaceholder: '10 digits, no hyphens',
+ companyPhonePlaceholder: 'Phone Number (xxx)xxx-xxxx',
},
requestorStep: {
headerTitle: 'Personal information',
subtitle: 'Please provide your personal information.',
- financialRegulations: 'Financial regulation and bank rules require us to validate the identity of any individual setting up bank accounts on behalf of a company. ',
learnMore: 'Learn more',
isMyDataSafe: 'Is my data safe?',
onFidoConditions: 'By continuing with the request to add this bank account, you confirm that you have read, understand and accept ',
@@ -558,6 +591,9 @@ export default {
descriptionCTA: 'Please enter each transaction amount in the fields below. Example: 1.51',
reviewingInfo: 'Thanks! We\'re reviewing your information, and will be in touch shortly. Please check your chat with Concierge ',
forNextSteps: ' for next steps to finish setting up your bank account.',
+ letsChatCTA: 'Yes, let\'s chat!',
+ letsChatText: 'Thanks for doing that! We have a couple more things to work out, but it’ll be easier over chat. Ready to chat?',
+ letsChatTitle: 'Let\'s chat!',
},
beneficialOwnersStep: {
additionalInformation: 'Additional information',
@@ -575,7 +611,7 @@ export default {
certify: 'Must certify information is true and accurate',
},
},
- vbaLoadingAnimation: {
+ reimbursementAccountLoadingAnimation: {
oneMoment: 'One Moment',
explanationLine: 'We’re taking a look at your information. You will be able to continue with next steps shortly.',
},
@@ -585,9 +621,18 @@ export default {
},
workspace: {
common: {
- card: 'Expensify Card',
+ card: 'Issue corporate cards',
workspace: 'Workspace',
- edit: 'Edit workspace',
+ settings: 'General settings',
+ reimburse: 'Reimburse receipts',
+ bills: 'Pay bills',
+ invoices: 'Send invoices',
+ travel: 'Book travel',
+ members: 'Manage members',
+ bankAccount: 'Connect bank account',
+ issueAndManageCards: 'Issue and manage cards',
+ reconcileCards: 'Reconcile cards',
+ growlMessageOnSave: 'Your workspace settings were successfully saved!',
},
new: {
newWorkspace: 'New workspace',
@@ -596,23 +641,68 @@ export default {
},
people: {
genericFailureMessage: 'An error occurred removing a user from the workspace, please try again.',
- removeMembersPrompt: 'Are you sure you want to remove the selected people from your workspace?',
+ removeMembersPrompt: 'Are you sure you want to remove the selected members from your workspace?',
removeMembersTitle: 'Remove members',
selectAll: 'Select all',
+ error: {
+ cannotRemove: 'You cannot remove yourself or the workspace owner.',
+ },
},
card: {
- addEmail: 'Add email',
- tagline: 'The smartest corporate card in the room.',
- publicCopy: 'In order to use the Expensify Card you must use your company\'s private domain. Go ahead and add your private email address as a secondary login.',
- privateCopy: 'Just swipe your Expensify card and your expenses are done, it\'s that simple!',
- getStarted: 'Get started',
- finishSetup: 'Finish setup',
- manageCards: 'Manage cards',
- cardReadyTagline: 'Your Expensify Cards are ready to go!',
+ header: 'Unlock free Expensify Cards',
+ headerWithEcard: 'Cards are ready!',
+ noVBACopy: 'Connect a bank account to issue unlimited Expensify Cards for your workspace members and access all of these incredible benefits:',
+ VBANoECardCopy: 'Add a work email address to issue unlimited Expensify Cards for your workspace members, as well as all of these incredible benefits:',
+ conciergeCanHelp: 'Concierge can help you add a work email address to enable the Expensify Card.',
+ VBAWithECardCopy: 'Enjoy all these incredible benefits:',
+ benefit1: 'Up to 2% cash back',
+ benefit2: 'Digital and physical cards',
+ benefit3: 'No personal liability',
+ benefit4: 'Customizable limits',
+ chatWithConcierge: 'Chat with Concierge',
+ },
+ reimburse: {
+ captureReceipts: 'Capture receipts',
+ fastReimbursementsHappyMembers: 'Fast reimbursements = happy members!',
+ viewAllReceipts: 'View all receipts',
+ reimburseReceipts: 'Reimburse receipts',
+ unlockNextDayReimbursements: 'Unlock next-day reimbursements',
+ captureNoVBACopyBeforeEmail: 'Ask your workspace members to forward receipts to ',
+ captureNoVBACopyAfterEmail: ' and download the Expensify App to track cash expenses on the go.',
+ unlockNoVBACopy: 'Connect a bank account to reimburse your workspace members online.',
+ fastReimbursementsVBACopy: 'You\'re all set to reimburse receipts from your bank account!',
+ },
+ bills: {
+ manageYourBills: 'Manage your bills',
+ askYourVendorsBeforeEmail: 'Ask your vendors to forward their invoices to ',
+ askYourVendorsAfterEmail: ' and we\'ll scan them for you to pay.',
+ viewAllBills: 'View all bills',
+ unlockOnlineBillPayment: 'Unlock online bill payment',
+ unlockNoVBACopy: 'Connect your bank account to pay bills online for free!',
+ hassleFreeBills: 'Hassle-free bills!',
+ VBACopy: 'You\'re all set to make payments from your bank account!',
+ },
+ invoices: {
+ invoiceClientsAndCustomers: 'Invoice clients and customers',
+ invoiceFirstSectionCopy: 'Send beautiful, professional invoices directly to your clients and customers right from within the Expensify app.',
+ viewAllInvoices: 'View all invoices',
+ unlockOnlineInvoiceCollection: 'Unlock online invoice collection',
+ unlockNoVBACopy: 'Connect your bank account to accept online payments for invoices - by ACH or credit card - to be deposited straight into your account.',
+ moneyBackInAFlash: 'Money back, in a flash!',
+ unlockVBACopy: 'You\'re all set to accept payments by ACH or credit card!',
+ viewUnpaidInvoices: 'View unpaid invoices',
+ sendInvoice: 'Send invoice',
+ },
+ travel: {
+ unlockConciergeBookingTravel: 'Unlock Concierge travel booking',
+ noVBACopy: 'Connect your bank account to let workspace members book their flights, hotels, and cars by starting a chat with Concierge.',
+ packYourBags: 'Pack your bags!',
+ VBACopy: 'Members with the Expensify card can chat with Concierge to book travel!',
+ bookTravelWithConcierge: 'Book travel with Concierge',
},
invite: {
- invitePeople: 'Invite people',
- invitePeoplePrompt: 'Invite colleagues to your workspace.',
+ invitePeople: 'Invite new members',
+ invitePeoplePrompt: 'Invite new members to your workspace.',
personalMessagePrompt: 'Add a personal message (optional)',
enterEmailOrPhone: 'Emails or phone numbers',
EmailOrPhonePlaceholder: 'Enter comma-separated list of emails or phone numbers',
@@ -620,31 +710,42 @@ export default {
pleaseEnterUniqueLogin: 'That user is already a member of this workspace.',
genericFailureMessage: 'An error occurred inviting the user to the workspace, please try again.',
systemUserError: ({email}) => `Sorry, you cannot invite ${email} to a workspace.`,
- welcomeNote: ({workspaceName}) => `You have been invited to the ${workspaceName} workspace! Download the Expensify mobile app to start tracking your expenses.`,
+ welcomeNote: ({workspaceName}) => `You have been invited to ${workspaceName}! Download the Expensify mobile app to start tracking your expenses.`,
},
editor: {
nameInputLabel: 'Name',
nameInputHelpText: 'This is the name you will see on your workspace.',
+ nameIsRequiredError: 'You need to define a name for your workspace',
+ currencyInputLabel: 'Default currency',
+ currencyInputHelpText: 'All expenses on this workspace will be converted to this currency.',
save: 'Save',
genericFailureMessage: 'An error occurred updating the workspace, please try again.',
avatarUploadFailureMessage: 'An error occurred uploading the avatar, please try again.',
},
- error: {
- growlMessageInvalidPolicy: 'Invalid workspace!',
+ bankAccount: {
+ continueWithSetup: 'Continue with setup',
+ youreAlmostDone: 'You\'re almost done setting up your bank account, which will let you issue corporate cards, reimburse expenses, collect invoices, and pay bills all from the same bank account.',
+ streamlinePayments: 'Streamline payments',
+ oneMoreThing: 'One more thing!',
+ allSet: 'You\'re all set!',
+ accountDescriptionNoCards: 'This bank account will be used to reimburse expenses, collect invoices, and pay bills all from the same account.\n\nConcierge can help you add a work email address to enable the Expensify Card.',
+ accountDescriptionWithCards: 'This bank account will be used to issue corporate cards, reimburse expenses, collect invoices, and pay bills all from the same account.',
+ chatWithConcierge: 'Chat with Concierge',
+ letsFinishInChat: 'Let\'s finish in chat!',
+ almostDone: 'Almost done!',
},
},
requestCallPage: {
- requestACall: 'Request a call',
- description: 'Need help with your account configuration? Our team of guides are on hand to help you each step of the way.',
- instructions: 'Type in your name and phone number, and we’ll give you a call back.',
- availabilityText: '*Our guides are available from Sunday at 5pm CT to Friday at 5pm CT. Any requests outside this window will be returned 9am - 5pm, Monday - Friday in your local time. Call time is based on the order the call was received.',
+ title: 'Request a call',
+ subtitle: 'Have questions, or need help?',
+ description: 'Our team of guides are on hand to help you each step of the way. Type in your name and phone number, and we’ll give you a call back.',
callMe: 'Call me',
growlMessageOnSave: 'Call requested.',
errorMessageInvalidPhone: 'That doesn’t look like a valid phone number. Try again with the country code. e.g. +15005550006',
growlMessageEmptyName: 'Please provide both a first and last name so our guides know how to address you!',
growlMessageNoPersonalPolicy: 'I wasn’t able to find a personal policy to associate this Guides call with, please check your connection and try again.',
- needHelp: 'Help',
- needHelpTooltip: 'Get live help from our team',
+ callButton: 'Call',
+ callButtonTooltip: 'Get live help from our team',
},
emojiPicker: {
skinTonePickerLabel: 'Change default skin tone',
diff --git a/src/languages/es.js b/src/languages/es.js
index bbaadd34ef2..97e07a8b1d0 100644
--- a/src/languages/es.js
+++ b/src/languages/es.js
@@ -26,6 +26,7 @@ export default {
continue: 'Continuar',
firstName: 'Primer nombre',
lastName: 'Apellido',
+ phone: 'teléfono',
phoneNumber: 'Número de teléfono',
email: 'Email',
and: 'y',
@@ -44,21 +45,20 @@ export default {
saveAndContinue: 'Guardar y continuar',
settings: 'Configuración',
termsOfService: 'Términos de servicio',
- people: 'Personas',
+ members: 'Miembros',
invite: 'Invitación',
here: 'aquí',
dob: 'Fecha de Nacimiento',
ssnLast4: 'Últimos 4 dígitos de su SSN',
personalAddress: 'Dirección física personal',
companyAddress: 'Dirección física de la empresa',
- noPO: '(No se aceptan apartados ni direcciones postales)',
+ noPO: 'No se aceptan apartados ni direcciones postales',
city: 'Ciudad',
state: 'Estado',
zip: 'Código postal',
isRequiredField: 'es un campo obligatorio',
whatThis: '¿Qué es esto?',
iAcceptThe: 'Acepto los ',
- passwordCannotBeBlank: 'La contraseña no puede estar vacía',
remove: 'Eliminar',
admin: 'Administrador',
dateFormat: 'AAAA-MM-DD',
@@ -78,6 +78,12 @@ export default {
},
please: 'Por favor',
contactUs: 'contáctenos',
+ pleaseEnterEmailOrPhoneNumber: 'Por favor escribe un email o número de teléfono',
+ fixTheErrors: 'corrige los errores',
+ inTheFormBeforeContinuing: 'en el formulario antes de continuar',
+ confirm: 'Confirmar',
+ reset: 'Restablecer',
+ done: 'Listo',
},
attachmentPicker: {
cameraPermissionRequired: 'Se necesita permiso para usar la cámara',
@@ -199,7 +205,6 @@ export default {
always: 'Siempre',
daily: 'Cada día',
mute: 'Nunca',
- members: 'Miembros',
},
loginField: {
addYourPhoneToSettleViaVenmo: 'Agrega tu número de teléfono para pagar usando Venmo.',
@@ -278,6 +283,31 @@ export default {
growlMessageOnSave: 'Su nombre de usuario de PayPal se agregó correctamente',
editPayPalAccount: 'Actualizar cuenta de PayPal',
},
+ addDebitCardPage: {
+ addADebitCard: 'Agregar una tarjeta de débito',
+ nameOnCard: 'Nombre en la tarjeta',
+ debitCardNumber: 'Numero de la tarjeta de débito',
+ expiration: 'Vencimiento',
+ expirationDate: 'MM/AA',
+ cvv: 'CVV',
+ billingAddress: 'Dirección de Envio',
+ streetAddress: 'Dirección',
+ cityName: 'Nombre de la ciudad',
+ expensifyTermsOfService: 'Expensify Términos de servicio',
+ growlMessageOnSave: 'Su tarteja de débito se agregó correctamente',
+ error: {
+ invalidName: 'Por favor agregue un nombre válido',
+ zipCode: 'Por favor ingrese un código postal válido',
+ debitCardNumber: 'Ingrese un número de tarjeta de débito válido',
+ expirationDate: 'Por favor introduzca una fecha de vencimiento válida',
+ securityCode: 'Ingrese un código de seguridad válido',
+ address: 'Ingrese una dirección de facturación válida',
+ addressState: 'Por favor seleccione un estado',
+ addressCity: 'Por favor ingrese una ciudad',
+ acceptedTerms: 'Debes aceptar los Términos de servicio para continuar',
+ genericFailureMessage: 'Se produjo un error al agregar su tarjeta. Vuelva a intentarlo',
+ },
+ },
paymentsPage: {
paymentMethodsTitle: 'Métodos de pago',
},
@@ -285,7 +315,7 @@ export default {
addPaymentMethod: 'Agrega método de pago',
accountLastFour: 'Cuenta con terminación',
cardLastFour: 'Tarjeta con terminacíon',
- addFirstPaymentMethod: 'Añade un método de pago para enviar y recibir pagos directamente desde la aplicación',
+ addFirstPaymentMethod: 'Añade un método de pago para enviar y recibir pagos directamente desde la aplicación.',
},
preferencesPage: {
mostRecent: 'Más recientes',
@@ -341,7 +371,6 @@ export default {
},
},
loginForm: {
- pleaseEnterEmailOrPhoneNumber: 'Por favor escribe un email o número de teléfono',
phoneOrEmail: 'Número de teléfono o email',
error: {
invalidFormatLogin: 'El email o número de teléfono que has introducido no es válido. Corrígelo e inténtalo de nuevo.',
@@ -349,8 +378,10 @@ export default {
},
resendValidationForm: {
linkHasBeenResent: 'El enlace se ha reenviado',
- weSentYouMagicSignInLink: ({loginType}) => `Hemos enviado un enlace mágico de inicio de sesión a tu ${loginType}.`,
+ weSentYouMagicSignInLink: ({login}) => `Hemos enviado un enlace mágico de inicio de sesión a ${login}. Verifica tu bandeja de entrada y tu carpeta de correo no deseado y espera de 5 a 10 minutos antes de intentarlo de nuevo.`,
resendLink: 'Reenviar enlace',
+ unvalidatedAccount: 'Esta cuenta existe pero no está validada, por favor busca el enlace mágico en tu bandeja de entrada',
+ newAccount: ({login, loginType}) => `¡Bienvenido ${login}, es genial ver una cara nueva por aquí! En tu ${loginType} encontrarás un enlace para validar tu cuenta, por favor, revísalo`,
},
detailsPage: {
localTime: 'Hora local',
@@ -373,19 +404,21 @@ export default {
passwordNotSet: 'No pudimos establecer to contaseña correctamente.',
accountNotValidated: 'No pudimos validar tu cuenta. Es posible que el enlace de validación haya caducado.',
},
+ stepCounter: ({step, total}) => `Paso ${step} de ${total}`,
bankAccount: {
accountNumber: 'Número de cuenta',
routingNumber: 'Número de ruta',
addBankAccount: 'Agregar cuenta bancaria',
chooseAnAccount: 'Elige una cuenta',
- logIntoYourBank: 'Inicia sesión en su banco',
+ connectOnlineWithPlaid: 'Conéctate a Plaid online',
connectManually: 'Conectar manualmente',
yourDataIsSecure: 'Tus datos están seguros',
- toGetStarted: 'Para comenzar con la tarjeta Expensify, primero debe agregar una cuenta bancaria.',
+ toGetStarted: 'Añade una cuenta bancaria y emite tarjetas corporativas, reembolsa gastos y cobra y paga facturas, todo desde un mismo sitio.',
plaidBodyCopy: 'Ofrezca a sus empleados una forma más sencilla de pagar - y recuperar - los gastos de la empresa.',
checkHelpLine: 'Su número de ruta y número de cuenta se pueden encontrar en un cheque de la cuenta bancaria.',
+ validateAccountError: 'Para terminar de configurar tu cuenta bancaria, debes validar tu cuenta de Expensify. Por favor revisa tu correo electrónico para validar tu cuenta y regresa aquí para continuar.',
hasPhoneLoginError: 'Para agregar una cuenta bancaria verificada, asegúrate de que tu nombre de usuario principal sea un correo electrónico válido y vuelve a intentarlo. Puedes agregar tu número de teléfono como nombre de usuario secundario.',
- hasBeenThrottledError: ({fromNow}) => `Por razones de seguridad, nos tomamos un descanso en la configuración de la cuenta bancaria para que pueda verificar la información de su empresa. Inténtalo de nuevo ${fromNow}. ¡Lo siento!`,
+ hasBeenThrottledError: 'Se produjo un error al intentar agregar tu cuenta bancaria. Por favor, espera unos minutos e inténtalo de nuevo.',
buttonConfirm: 'OK',
error: {
noBankAccountAvailable: 'Lo sentimos, no hay ninguna cuenta bancaria disponible',
@@ -397,8 +430,8 @@ export default {
addressCity: 'Ingresa una ciudad válida',
addressStreet: 'Ingresa una calle de dirección válida que no sea un apartado postal',
addressState: 'Por favor, selecciona un estado',
- incorporationDate: 'Ingresa una fecha de incorporación válida',
- incorporationState: 'Ingresa un estado de incorporación válido',
+ incorporationDate: 'Ingresa una fecha válida',
+ incorporationState: 'Ingresa un estado válido',
industryCode: 'Ingresa un código de clasificación de industria válido',
restrictedBusiness: 'Confirma que la empresa no está en la lista de negocios restringidos',
routingNumber: 'Ingresa un número de ruta válido',
@@ -407,13 +440,12 @@ export default {
tooManyAttempts: 'Debido a la gran cantidad de intentos de inicio de sesión, esta opción se ha desactivado temporalmente durante 24 horas. Vuelve a intentarlo más tarde o introduzca los detalles manualmente.',
address: 'Ingresa una dirección válida',
dob: 'Ingresa una fecha de nacimiento válida',
- age: 'Los solicitantes deben ser mayores de 18 años',
+ age: 'Debe ser mayor de 18 años',
ssnLast4: 'Ingresa los últimos 4 dígitos del número de seguro social',
firstName: 'Ingresa un nombre válido',
lastName: 'Ingresa un apellido válido',
noDefaultDepositAccountOrDebitCardAvailable: 'Por favor agrega una cuenta bancaria para depósitos o una tarjeta de débito',
- fixTheErrors: 'corrige los errores',
- inTheFormBeforeContinuing: 'en el formulario antes de continuar',
+ validationAmounts: 'Los montos de validación que ingresaste son incorrectos. Verifica tu cuenta de banco e intenta de nuevo.',
},
},
addPersonalBankAccountPage: {
@@ -529,22 +561,23 @@ export default {
},
companyStep: {
headerTitle: 'Información de la empresa',
- subtitle: 'Dé más información sobre su empresa.',
+ subtitle: '¡Ya casi estamos! Por motivos de seguridad, necesitamos confirmar la siguiente información:',
legalBusinessName: 'Nombre comercial legal',
companyWebsite: 'Página web de la empresa',
taxIDNumber: 'Número de identificación fiscal',
+ taxIDNumberPlaceholder: '9 dígitos, sin guiones',
companyType: 'Tipo de empresa',
incorporationDate: 'Fecha de incorporación',
+ incorporationState: 'Estado de incorporación',
industryClassificationCode: 'Código de clasificación industrial',
confirmCompanyIsNot: 'Confirmo que esta empresa no está en el',
listOfRestrictedBusinesses: 'lista de negocios restringidos',
incorporationDatePlaceholder: 'Fecha de inicio (aaaa-mm-dd)',
- companyPhonePlaceholder: '10 dígitos, sin guiones',
+ companyPhonePlaceholder: '(prefijo) + (número)',
},
requestorStep: {
headerTitle: 'Información personal',
subtitle: 'Dé más información sobre tí.',
- financialRegulations: 'Las leyes fiscales y el reglamento bancario nos obliga a verificar la identidad de todo individuo que desee añadir una cuenta bancaria representando a una compañía. ',
learnMore: 'Más información',
isMyDataSafe: '¿Están seguros mis datos?',
onFidoConditions: 'Al continuar con la solicitud de añadir esta cuenta bancaria, confirma que ha leído, entiende y acepta ',
@@ -560,6 +593,9 @@ export default {
descriptionCTA: 'Ingrese el monto de cada transacción en los campos a continuación. Ejemplo: 1.51',
reviewingInfo: '¡Gracias! Estamos revisando tu información y nos comunicaremos contigo en breve. Consulte su chat con Concierge ',
forNextSteps: ' para conocer los próximos pasos para terminar de configurar su cuenta bancaria.',
+ letsChatCTA: '¡Sí, vamos a chatear!',
+ letsChatText: '¡Gracias por hacer eso! Todavía tenemos que solucionar un par de cosas, pero será más fácil por chat. ¿Listo para charlar?',
+ letsChatTitle: '¡Vamos a chatear!',
},
beneficialOwnersStep: {
additionalInformation: 'Información adicional',
@@ -577,7 +613,7 @@ export default {
certify: 'Debe certificar que la información es verdadera y precisa',
},
},
- vbaLoadingAnimation: {
+ reimbursementAccountLoadingAnimation: {
oneMoment: 'Un Momento',
explanationLine: 'Estamos verificando tu información y podrás continuar con los siguientes pasos en unos momentos.',
},
@@ -587,9 +623,18 @@ export default {
},
workspace: {
common: {
- card: 'Tarjeta Expensify',
+ card: 'Emitir tarjetas corporativas',
workspace: 'Espacio de trabajo',
- edit: 'Editar espacio de trabajo',
+ settings: 'Configuración general',
+ reimburse: 'Reembolsar recibos',
+ bills: 'Pagar facturas',
+ invoices: 'Enviar facturas',
+ travel: 'Reservar viaje',
+ members: 'Gestionar miembros',
+ bankAccount: 'Conectar cuenta bancaria',
+ issueAndManageCards: 'Emitir y gestionar tarjetas',
+ reconcileCards: 'Reconciliar tarjetas',
+ growlMessageOnSave: '¡La configuración del espacio de trabajo se ha guardado correctamente!',
},
new: {
newWorkspace: 'Nuevo espacio de trabajo',
@@ -598,23 +643,68 @@ export default {
},
people: {
genericFailureMessage: 'Se ha producido un error al intentar eliminar a un usuario del espacio de trabajo. Por favor inténtalo más tarde.',
- removeMembersPrompt: '¿Estás seguro que quieres eliminar a las personas seleccionadas de tu espacio de trabajo?',
+ removeMembersPrompt: '¿Estás seguro que quieres eliminar a los miembros seleccionados de tu espacio de trabajo?',
removeMembersTitle: 'Eliminar miembros',
selectAll: 'Seleccionar todo',
+ error: {
+ cannotRemove: 'No puedes eliminarte ni a ti mismo ni al dueño del espacio de trabajo.',
+ },
},
card: {
- addEmail: 'Agregar correo electrónico',
- tagline: 'La tarjeta corporativa más inteligente de la habitación.',
- publicCopy: 'Para utilizar la Tarjeta Expensify debe utilizar el dominio privado de su empresa. Continúe y agregue su dirección de correo electrónico privada como inicio de sesión secundario.',
- privateCopy: 'Simplemente deslice su Tarjeta Expensify y sus gastos estarán listos, ¡es así de simple!',
- getStarted: 'Empezar',
- finishSetup: 'Finalizar configuración',
- manageCards: 'Administrar tarjetas',
- cardReadyTagline: 'Tus tarjetas Expensify están listas para usar!',
+ header: 'Desbloquea Tarjetas Expensify gratis',
+ headerWithEcard: '¡Tus tarjetas están listas!',
+ noVBACopy: 'Conecta una cuenta bancaria para emitir Tarjetas Expensify ilimitadas para los miembros de tu espacio de trabajo y acceder a todas estas increíbles ventajas:',
+ VBANoECardCopy: 'Agrega tu correo electrónico de trabajo para emitir Tarjetas Expensify ilimitadas para los miembros de tu espacio de trabajo y acceder a todas estas increíbles ventajas:',
+ conciergeCanHelp: 'Concierge te puede ayudar a añadir un correo electrónico de trabajo para activar la Tarjeta Expensify.',
+ VBAWithECardCopy: 'Disfruta de todas estas increíbles ventajas:',
+ benefit1: 'Hasta un 2% de devolución en tus gastos',
+ benefit2: 'Tarjetas digitales y físicas',
+ benefit3: 'Sin responsabilidad personal',
+ benefit4: 'Límites personalizables',
+ chatWithConcierge: 'Chatea con Concierge',
+ },
+ reimburse: {
+ captureReceipts: 'Captura recibos',
+ fastReimbursementsHappyMembers: '¡Reembolsos rápidos = miembros felices!',
+ viewAllReceipts: 'Ver todos los recibos',
+ reimburseReceipts: 'Reembolsar recibos',
+ unlockNextDayReimbursements: 'Desbloquea reembolsos diarios',
+ captureNoVBACopyBeforeEmail: 'Pide a los miembros de tu espacio de trabajo que envíen recibos a ',
+ captureNoVBACopyAfterEmail: ' y descarga la App de Expensify para controlar tus gastos en efectivo sobre la marcha.',
+ unlockNoVBACopy: 'Conecta una cuenta bancaria para reembolsar online a los miembros de tu espacio de trabajo.',
+ fastReimbursementsVBACopy: '¡Todo listo para reembolsar recibos desde tu cuenta bancaria!',
+ },
+ bills: {
+ manageYourBills: 'Gestiona tus facturas',
+ askYourVendorsBeforeEmail: 'Pide a tus proveedores que envíen sus facturas a ',
+ askYourVendorsAfterEmail: ' y las escanearemos para que las pagues.',
+ viewAllBills: 'Ver facturas recibidas',
+ unlockOnlineBillPayment: 'Desbloquea el pago de facturas online',
+ unlockNoVBACopy: '¡Conecta tu cuenta bancaria para pagar tus facturas online de manera gratuita!',
+ hassleFreeBills: '¡Facturas sin complicaciones!',
+ VBACopy: '¡Todo listo para realizar pagos desde tu cuenta bancaria!',
+ },
+ invoices: {
+ invoiceClientsAndCustomers: 'Emite facturas a tus clientes',
+ invoiceFirstSectionCopy: 'Envía facturas detalladas y profesionales directamente a tus clientes desde la app de Expensify.',
+ viewAllInvoices: 'Ver facturas emitidas',
+ unlockOnlineInvoiceCollection: 'Desbloquea el cobro de facturas online',
+ unlockNoVBACopy: 'Conecta tu cuenta bancaria para recibir pagos online de facturas - por transferencia o con tarjeta - directamente en tu cuenta.',
+ moneyBackInAFlash: '¡Tu dinero de vuelta en un momento!',
+ unlockVBACopy: '¡Todo listo para recibir pagos por transferencia o con tarjeta!',
+ viewUnpaidInvoices: 'Ver facturas emitidas pendientes',
+ sendInvoice: 'Enviar factura',
+ },
+ travel: {
+ unlockConciergeBookingTravel: 'Desbloquea la reserva de viajes con Concierge',
+ noVBACopy: 'Conecta tu cuenta bancaria para permitir a los miembros de tu espacio de trabajo reservar sus vuelos, hoteles y coches empezando una conversación con Concierge.',
+ packYourBags: '¡Haz las maletas!',
+ VBACopy: '¡Miembros con la tarjeta Expensify pueden hablar con Concierge para reservar viajes!',
+ bookTravelWithConcierge: 'Reserva viajes con Concierge',
},
invite: {
- invitePeople: 'Invitar a la gente',
- invitePeoplePrompt: 'Invita a tus compañeros a tu espacio de trabajo.',
+ invitePeople: 'Invitar nuevos miembros',
+ invitePeoplePrompt: 'Invita nuevos miembros a tu espacio de trabajo.',
personalMessagePrompt: 'Agregar un mensaje personal (Opcional)',
enterEmailOrPhone: 'Correos electrónicos o números de teléfono',
EmailOrPhonePlaceholder: 'Introduce una lista de correos electrónicos o números de teléfono separado por comas',
@@ -622,31 +712,42 @@ export default {
pleaseEnterUniqueLogin: 'Ese usuario ya es miembro de este espacio de trabajo.',
genericFailureMessage: 'Se produjo un error al invitar al usuario al espacio de trabajo. Vuelva a intentarlo..',
systemUserError: ({email}) => `Lo sentimos, no puedes invitar a ${email} a un espacio de trabajo.`,
- welcomeNote: ({workspaceName}) => `¡Has sido invitado a la ${workspaceName} Espacio de trabajo! Descargue la aplicación móvil Expensify para comenzar a rastrear sus gastos.`,
+ welcomeNote: ({workspaceName}) => `¡Has sido invitado a ${workspaceName}! Descargue la aplicación móvil Expensify para comenzar a rastrear sus gastos.`,
},
editor: {
nameInputLabel: 'Nombre',
nameInputHelpText: 'Este es el nombre que verás en tu espacio de trabajo.',
+ nameIsRequiredError: 'Debes definir un nombre para tu espacio de trabajo.',
+ currencyInputLabel: 'Moneda por defecto',
+ currencyInputHelpText: 'Todas los gastos en este epecio de trabajo serán convertidos a esta moneda.',
save: 'Guardar',
genericFailureMessage: 'Se produjo un error al guardar el espacio de trabajo. Por favor, inténtalo de nuevo.',
avatarUploadFailureMessage: 'No se pudo subir el avatar. Por favor, inténtalo de nuevo.',
},
- error: {
- growlMessageInvalidPolicy: '¡Espacio de trabajo no válido!',
+ bankAccount: {
+ continueWithSetup: 'Continuar con la configuración',
+ youreAlmostDone: 'Casi has acabado de configurar tu cuenta bancaria, que te permitirá emitir tarjetas corporativas, reembolsar gastos y cobrar pagar facturas, todo desde la misma cuenta bancaria.',
+ streamlinePayments: 'Optimiza pagos',
+ oneMoreThing: '¡Una cosa más!',
+ allSet: '¡Todo listo!',
+ accountDescriptionNoCards: 'Esta cuenta bancaria se utilizará para reembolsar gastos y cobrar y pagar facturas, todo desde la misma cuenta. Concierge puede ayudarte a añadir tu correo de trabajo para activar la Tarjeta Expensify.',
+ accountDescriptionWithCards: 'Esta cuenta bancaria se utilizará para emitir tarjetas corporativas, reembolsar gastos y cobrar y pagar facturas, todo desde la misma cuenta.',
+ chatWithConcierge: 'Chat con Concierge',
+ letsFinishInChat: '¡Acabemos en el chat!',
+ almostDone: '¡Casi listo!',
},
},
requestCallPage: {
- requestACall: 'Llámame por teléfono',
- description: '¿Necesitas ayuda configurando tu cuenta? Nuestro equipo de guías puede ayudarte.',
- instructions: 'Escribe tu nombre y número de teléfono y te llamaremos.',
- availabilityText: '*Nuestros guías están disponibles de domingo desde las 17.00 CT a viernes hasta las 17.00 CT. Si solicitas una llamada fuera de este horario, te llamaremos de lunes a viernes de 9.00 a 17.00 en tu hora local. El orden de llamada corresponde con el orden de solicitud.',
+ title: 'Llámame por teléfono',
+ subtitle: '¿Tienes preguntas o necesitas ayuda?',
+ description: '¿Necesitas ayuda configurando tu cuenta? Nuestro equipo de guías puede ayudarte. Escribe tu nombre y número de teléfono y te llamaremos.',
callMe: 'Llámame',
growlMessageOnSave: 'Llamada solicitada.',
errorMessageInvalidPhone: 'El teléfono no es valido. Inténtalo de nuevo agregando el código de país. P. ej.: +15005550006',
growlMessageEmptyName: 'Por favor ingresa tu nombre completo',
growlMessageNoPersonalPolicy: 'No he podido encontrar una póliza personal con la que asociar esta llamada a las Guías, compruebe su conexión e inténtelo de nuevo.',
- needHelp: 'Ayuda',
- needHelpTooltip: 'Recibe ayuda telefónica de nuestro equipo',
+ callButton: 'Llamar',
+ callButtonTooltip: 'Recibe ayuda telefónica de nuestro equipo',
},
emojiPicker: {
skinTonePickerLabel: 'Elige el tono de piel por defecto',
diff --git a/src/libs/API.js b/src/libs/API.js
index d817172f91e..b87711ef3ef 100644
--- a/src/libs/API.js
+++ b/src/libs/API.js
@@ -60,6 +60,7 @@ function addDefaultValuesToParameters(command, parameters) {
console.debug('A request was made without an authToken', {command, parameters});
Network.pauseRequestQueue();
Network.clearRequestQueue();
+ Network.unpauseRequestQueue();
return;
}
@@ -189,6 +190,7 @@ Network.registerErrorHandler((queuedRequest, error) => {
* @param {String} parameters.partnerUserSecret
* @param {String} [parameters.twoFactorAuthCode]
* @param {String} [parameters.email]
+ * @param {String} [parameters.authToken]
* @returns {Promise}
*/
function Authenticate(parameters) {
@@ -211,6 +213,7 @@ function Authenticate(parameters) {
partnerUserID: parameters.partnerUserID,
partnerUserSecret: parameters.partnerUserSecret,
twoFactorAuthCode: parameters.twoFactorAuthCode,
+ authToken: parameters.authToken,
doNotRetry: true,
// Force this request to be made because the network queue is paused when re-authentication is happening
@@ -263,6 +266,7 @@ function reauthenticate(command = '') {
partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD,
partnerUserID: credentials.autoGeneratedLogin,
partnerUserSecret: credentials.autoGeneratedPassword,
+ authToken,
})
.then((response) => {
// If authentication fails throw so that we hit
@@ -334,6 +338,16 @@ function AuthenticateWithAccountID(parameters) {
});
}
+/**
+ * @param {Object} parameters
+ * @returns {Promise}
+ */
+function AddBillingCard(parameters) {
+ const commandName = 'User_AddBillingCard';
+ return Network.post(commandName, parameters, CONST.NETWORK.METHOD.POST, true);
+}
+
+
/**
* @param {Object} parameters
* @param {String} parameters.oldPassword
@@ -433,11 +447,11 @@ function GetAccountStatus(parameters) {
}
/**
- * Returns a validate code for this account
+ * Returns a short lived authToken for this account
* @returns {Promise}
*/
-function GetAccountValidateCode() {
- const commandName = 'GetAccountValidateCode';
+function GetShortLivedAuthToken() {
+ const commandName = 'GetShortLivedAuthToken';
return Network.post(commandName);
}
@@ -965,7 +979,7 @@ function BankAccount_SetupWithdrawal(parameters) {
requireParameters(['currentStep'], parameters, commandName);
return Network.post(
- commandName, {additionalData: JSON.stringify(additionalData), password: parameters.password},
+ commandName, {additionalData: JSON.stringify(additionalData)},
CONST.NETWORK.METHOD.POST,
true,
);
@@ -1076,6 +1090,7 @@ function UpdatePolicy(parameters) {
export {
Authenticate,
AuthenticateWithAccountID,
+ AddBillingCard,
BankAccount_Create,
BankAccount_Get,
BankAccount_SetupWithdrawal,
@@ -1086,7 +1101,7 @@ export {
DeleteLogin,
Get,
GetAccountStatus,
- GetAccountValidateCode,
+ GetShortLivedAuthToken,
GetIOUReport,
GetPolicyList,
GetPolicySummaryList,
diff --git a/src/libs/MakeCancellablePromise.js b/src/libs/MakeCancellablePromise.js
new file mode 100644
index 00000000000..256a32ab6fc
--- /dev/null
+++ b/src/libs/MakeCancellablePromise.js
@@ -0,0 +1,22 @@
+/**
+ * Wrapper to make any promise cancellable
+ * from https://reactjs.org/blog/2015/12/16/ismounted-antipattern.html
+ *
+ * @param {Promise} promise
+ * @returns {Object} {{cancel(): void, promise: Promise}}
+ */
+export default function makeCancellablePromise(promise) {
+ let hasCancelled = false;
+
+ const wrappedPromise = new Promise((resolve, reject) => {
+ promise.then(val => (hasCancelled ? reject(new Error('Promise was cancelled')) : resolve(val)));
+ promise.catch(error => (hasCancelled ? reject(new Error('Promise was cancelled')) : reject(error)));
+ });
+
+ return {
+ promise: wrappedPromise,
+ cancel() {
+ hasCancelled = true;
+ },
+ };
+}
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js
index 5338f4cb283..809d6d1bffb 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.js
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.js
@@ -1,10 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
+import {Linking} from 'react-native';
import Onyx, {withOnyx} from 'react-native-onyx';
+import Str from 'expensify-common/lib/str';
import moment from 'moment';
import _ from 'underscore';
import lodashGet from 'lodash/get';
-import styles, {getNavigationModalCardStyle} from '../../../styles/styles';
+import {getNavigationModalCardStyle} from '../../../styles/styles';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions';
import CONST from '../../../CONST';
import compose from '../../compose';
@@ -27,10 +29,9 @@ import Navigation from '../Navigation';
import * as User from '../../actions/User';
import {setModalVisibility} from '../../actions/Modal';
import NameValuePair from '../../actions/NameValuePair';
-import {getPolicySummaries, getPolicyList} from '../../actions/Policy';
+import {getPolicyList} from '../../actions/Policy';
import modalCardStyleInterpolator from './modalCardStyleInterpolator';
import createCustomModalStackNavigator from './createCustomModalStackNavigator';
-import Permissions from '../../Permissions';
import getOperatingSystem from '../../getOperatingSystem';
import {fetchFreePlanVerifiedBankAccount} from '../../actions/BankAccounts';
@@ -54,22 +55,17 @@ import {
SettingsModalStackNavigator,
EnablePaymentsStackNavigator,
AddPersonalBankAccountModalStackNavigator,
- ReimbursementAccountModalStackNavigator,
WorkspaceInviteModalStackNavigator,
RequestCallModalStackNavigator,
ReportDetailsModalStackNavigator,
- WorkspaceEditorNavigator,
} from './ModalStackNavigators';
import SCREENS from '../../../SCREENS';
import Timers from '../../Timers';
-import LoginWithValidateCodePage from '../../../pages/LoginWithValidateCodePage';
-import LoginWithValidateCode2FAPage from '../../../pages/LoginWithValidateCode2FAPage';
-import WorkspaceSettingsDrawerNavigator from './WorkspaceSettingsDrawerNavigator';
-import spacing from '../../../styles/utilities/spacing';
-import CardOverlay from '../../../components/CardOverlay';
+import LogInWithShortLivedTokenPage from '../../../pages/LogInWithShortLivedTokenPage';
import defaultScreenOptions from './defaultScreenOptions';
import * as API from '../../API';
import {setLocale} from '../../actions/App';
+import {cleanupSession} from '../../actions/Session';
Onyx.connect({
key: ONYXKEYS.MY_PERSONAL_DETAILS,
@@ -111,22 +107,6 @@ const modalScreenListeners = {
},
};
-let hasLoadedPolicies = false;
-
-/**
- * We want to only load policy info if you are in the freePlan beta.
- * @param {Array} betas
- */
-function loadPoliciesBehindBeta(betas) {
- // When removing the freePlan beta, simply load the policyList and the policySummaries in componentDidMount().
- // Policy info loading should not be blocked behind the defaultRooms beta alone.
- if (!hasLoadedPolicies && (Permissions.canUseFreePlan(betas) || Permissions.canUseDefaultRooms(betas))) {
- getPolicyList();
- getPolicySummaries();
- hasLoadedPolicies = true;
- }
-}
-
const propTypes = {
/** Information about the network */
network: PropTypes.shape({
@@ -134,15 +114,11 @@ const propTypes = {
isOffline: PropTypes.bool,
}),
- /** List of betas available to current user */
- betas: PropTypes.arrayOf(PropTypes.string),
-
...windowDimensionsPropTypes,
};
const defaultProps = {
network: {isOffline: true},
- betas: [],
};
class AuthScreens extends React.Component {
@@ -191,7 +167,19 @@ class AuthScreens extends React.Component {
UnreadIndicatorUpdater.listenForReportChanges();
fetchFreePlanVerifiedBankAccount();
- loadPoliciesBehindBeta(this.props.betas);
+ // Load policies, maybe creating a new policy first.
+ Linking.getInitialURL()
+ .then((url) => {
+ // url is null on mobile unless the app was opened via a deeplink
+ if (url) {
+ const path = new URL(url).pathname;
+ const exitTo = new URLSearchParams(url).get('exitTo');
+ const shouldCreateFreePolicy = Str.startsWith(path, Str.normalizeUrl(ROUTES.LOGIN_WITH_SHORT_LIVED_TOKEN)) && exitTo === ROUTES.WORKSPACE_NEW;
+ getPolicyList(shouldCreateFreePolicy);
+ } else {
+ getPolicyList(false);
+ }
+ });
// Refresh the personal details, timezone and betas every 30 minutes
// There is no pusher event that sends updated personal details data yet
@@ -227,19 +215,7 @@ class AuthScreens extends React.Component {
}
shouldComponentUpdate(nextProps) {
- if (nextProps.isSmallScreenWidth !== this.props.isSmallScreenWidth) {
- return true;
- }
-
- if (nextProps.betas !== this.props.betas) {
- return true;
- }
-
- return false;
- }
-
- componentDidUpdate() {
- loadPoliciesBehindBeta(this.props.betas);
+ return nextProps.isSmallScreenWidth !== this.props.isSmallScreenWidth;
}
componentWillUnmount() {
@@ -249,10 +225,9 @@ class AuthScreens extends React.Component {
if (this.unsubscribeGroupShortcut) {
this.unsubscribeGroupShortcut();
}
- NetworkConnection.stopListeningForReconnect();
+ cleanupSession();
clearInterval(this.interval);
this.interval = null;
- hasLoadedPolicies = false;
}
render() {
@@ -275,17 +250,6 @@ class AuthScreens extends React.Component {
// when displaying a modal. This allows us to dismiss by clicking outside on web / large screens.
isModal: true,
};
- const fullscreenModalScreenOptions = {
- ...commonModalScreenOptions,
- cardStyle: {
- ...styles.fullscreenCard,
- padding: this.props.isSmallScreenWidth ? spacing.p0.padding : spacing.p5.padding,
- },
- cardStyleInterpolator: props => modalCardStyleInterpolator(this.props.isSmallScreenWidth, true, props),
- cardOverlayEnabled: !this.props.isSmallScreenWidth,
- isFullScreenModal: true,
- cardOverlay: CardOverlay,
- };
return (
-
-
-
{/* These are the various modal routes */}
@@ -346,12 +295,6 @@ class AuthScreens extends React.Component {
modal subscreens e.g. `/settings/profile` and this will allow us to navigate while inside the modal. We
are also using a custom navigator on web so even if a modal does not have any subscreens it still must
use a navigator */}
-
-
-
);
}
@@ -466,8 +396,5 @@ export default compose(
network: {
key: ONYXKEYS.NETWORK,
},
- betas: {
- key: ONYXKEYS.BETAS,
- },
}),
)(AuthScreens);
diff --git a/src/libs/Navigation/AppNavigator/MainDrawerNavigator.js b/src/libs/Navigation/AppNavigator/MainDrawerNavigator.js
index fafe54572ab..fac1a19acae 100644
--- a/src/libs/Navigation/AppNavigator/MainDrawerNavigator.js
+++ b/src/libs/Navigation/AppNavigator/MainDrawerNavigator.js
@@ -50,7 +50,7 @@ const MainDrawerNavigator = (props) => {
// Wait until reports are fetched and there is a reportID in initialParams
if (!initialParams.reportID) {
- return ;
+ return ;
}
// After the app initializes and reports are available the home navigation is mounted
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
index 9b47dfced20..10fd8b38d11 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
@@ -18,6 +18,7 @@ import SettingsAppDownloadLinks from '../../../pages/settings/AppDownloadLinks';
import SettingsPasswordPage from '../../../pages/settings/PasswordPage';
import SettingsPaymentsPage from '../../../pages/settings/Payments/PaymentsPage';
import SettingsAddPayPalMePage from '../../../pages/settings/Payments/AddPayPalMePage';
+import SettingsAddDebitCardPage from '../../../pages/settings/Payments/AddDebitCardPage';
import SettingsAddSecondaryLoginPage from '../../../pages/settings/AddSecondaryLoginPage';
import IOUCurrencySelection from '../../../pages/iou/IOUCurrencySelection';
import ReportParticipantsPage from '../../../pages/ReportParticipantsPage';
@@ -27,7 +28,16 @@ import WorkspaceInvitePage from '../../../pages/workspace/WorkspaceInvitePage';
import ReimbursementAccountPage from '../../../pages/ReimbursementAccount/ReimbursementAccountPage';
import RequestCallPage from '../../../pages/RequestCallPage';
import ReportDetailsPage from '../../../pages/ReportDetailsPage';
-import WorkspaceEditorPage from '../../../pages/workspace/WorkspaceEditorPage';
+import WorkspaceSettingsPage from '../../../pages/workspace/WorkspaceSettingsPage';
+import WorkspaceInitialPage from '../../../pages/workspace/WorkspaceInitialPage';
+import WorkspaceCardPage from '../../../pages/workspace/card/WorkspaceCardPage';
+import WorkspaceReimbursePage from '../../../pages/workspace/reimburse/WorkspaceReimbursePage';
+import WorkspaceInvoicesPage from '../../../pages/workspace/invoices/WorkspaceInvoicesPage';
+import WorkspaceBillsPage from '../../../pages/workspace/bills/WorkspaceBillsPage';
+import WorkspaceTravelPage from '../../../pages/workspace/travel/WorkspaceTravelPage';
+import WorkspaceMembersPage from '../../../pages/workspace/WorkspaceMembersPage';
+import WorkspaceBankAccountPage from '../../../pages/workspace/WorkspaceBankAccountPage';
+import CONST from '../../../CONST';
const defaultSubRouteOptions = {
cardStyle: styles.navigationScreenCardStyle,
@@ -54,6 +64,7 @@ function createModalStackNavigator(screens) {
key={screen.name}
name={screen.name}
component={screen.Component}
+ initialParams={screen.initialParams}
/>
))}
@@ -165,6 +176,51 @@ const SettingsModalStackNavigator = createModalStackNavigator([
Component: SettingsAddPayPalMePage,
name: 'Settings_Add_Paypal_Me',
},
+ {
+ Component: SettingsAddDebitCardPage,
+ name: 'Settings_Add_Debit_Card',
+ },
+ {
+ Component: WorkspaceInitialPage,
+ name: 'Workspace_Initial',
+ },
+ {
+ Component: WorkspaceSettingsPage,
+ name: 'Workspace_Settings',
+ },
+ {
+ Component: WorkspaceCardPage,
+ name: 'Workspace_Card',
+ },
+ {
+ Component: WorkspaceReimbursePage,
+ name: 'Workspace_Reimburse',
+ },
+ {
+ Component: WorkspaceBillsPage,
+ name: 'Workspace_Bills',
+ },
+ {
+ Component: WorkspaceInvoicesPage,
+ name: 'Workspace_Invoices',
+ },
+ {
+ Component: WorkspaceTravelPage,
+ name: 'Workspace_Travel',
+ },
+ {
+ Component: WorkspaceMembersPage,
+ name: 'Workspace_Members',
+ },
+ {
+ Component: WorkspaceBankAccountPage,
+ name: 'Workspace_BankAccount',
+ },
+ {
+ Component: ReimbursementAccountPage,
+ name: 'ReimbursementAccount',
+ initialParams: {stepToOpen: CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT},
+ },
]);
const EnablePaymentsStackNavigator = createModalStackNavigator([{
@@ -192,11 +248,6 @@ const RequestCallModalStackNavigator = createModalStackNavigator([{
name: 'RequestCall_Root',
}]);
-const WorkspaceEditorNavigator = createModalStackNavigator([{
- Component: WorkspaceEditorPage,
- name: 'WorkspaceEditor_Root',
-}]);
-
export {
IOUBillStackNavigator,
IOURequestModalStackNavigator,
@@ -214,5 +265,4 @@ export {
ReimbursementAccountModalStackNavigator,
WorkspaceInviteModalStackNavigator,
RequestCallModalStackNavigator,
- WorkspaceEditorNavigator,
};
diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.js b/src/libs/Navigation/AppNavigator/PublicScreens.js
index 2ea797d0ddb..f95fe6b2dd5 100644
--- a/src/libs/Navigation/AppNavigator/PublicScreens.js
+++ b/src/libs/Navigation/AppNavigator/PublicScreens.js
@@ -3,6 +3,7 @@ import {createStackNavigator} from '@react-navigation/stack';
import SignInPage from '../../../pages/signin/SignInPage';
import SetPasswordPage from '../../../pages/SetPasswordPage';
import ValidateLoginPage from '../../../pages/ValidateLoginPage';
+import LogInWithShortLivedTokenPage from '../../../pages/LogInWithShortLivedTokenPage';
import SCREENS from '../../../SCREENS';
import LoginWithValidateCodePage from '../../../pages/LoginWithValidateCodePage';
import LoginWithValidateCode2FAPage from '../../../pages/LoginWithValidateCode2FAPage';
@@ -17,6 +18,11 @@ export default () => (
options={defaultScreenOptions}
component={SignInPage}
/>
+
(
- }
- screens={[
- {
- name: 'WorkspaceCard',
- component: WorkspaceCardPage,
- initialParams: {},
- },
- {
- name: 'WorkspacePeople',
- component: WorkspacePeoplePage,
- initialParams: {},
- },
- ]}
- />
-);
-
-WorkspaceSettingsDrawerNavigator.displayName = 'WorkspaceSettingsDrawerNavigator';
-
-export default WorkspaceSettingsDrawerNavigator;
diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js
index ff55c65c5e9..12279df62d2 100644
--- a/src/libs/Navigation/Navigation.js
+++ b/src/libs/Navigation/Navigation.js
@@ -38,6 +38,10 @@ function setDidTapNotification() {
* @private
*/
function openDrawer() {
+ if (!navigationRef.isReady()) {
+ console.debug('[Navigation] openDrawer failed because navigation ref was not yet ready');
+ return;
+ }
navigationRef.current.dispatch(DrawerActions.openDrawer());
}
@@ -46,6 +50,10 @@ function openDrawer() {
* @private
*/
function closeDrawer() {
+ if (!navigationRef.isReady()) {
+ console.debug('[Navigation] closeDrawer failed because navigation ref was not yet ready');
+ return;
+ }
navigationRef.current.dispatch(DrawerActions.closeDrawer());
}
@@ -65,6 +73,11 @@ function getDefaultDrawerState(isSmallScreenWidth) {
* @param {Boolean} shouldOpenDrawer
*/
function goBack(shouldOpenDrawer = true) {
+ if (!navigationRef.isReady()) {
+ console.debug('[Navigation] goBack failed because navigation ref was not yet ready');
+ return;
+ }
+
if (!navigationRef.current.canGoBack()) {
console.debug('Unable to go back');
if (shouldOpenDrawer) {
@@ -81,6 +94,11 @@ function goBack(shouldOpenDrawer = true) {
* @param {String} route
*/
function navigate(route = ROUTES.HOME) {
+ if (!navigationRef.isReady()) {
+ console.debug('[Navigation] navigate failed because navigation ref was not yet ready');
+ return;
+ }
+
if (route === ROUTES.HOME) {
if (isLoggedIn) {
openDrawer();
@@ -110,6 +128,11 @@ function navigate(route = ROUTES.HOME) {
* @param {Boolean} shouldOpenDrawer
*/
function dismissModal(shouldOpenDrawer = false) {
+ if (!navigationRef.isReady()) {
+ console.debug('[Navigation] dismissModal failed because navigation ref was not yet ready');
+ return;
+ }
+
const normalizedShouldOpenDrawer = _.isBoolean(shouldOpenDrawer)
? shouldOpenDrawer
: false;
diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js
index 903b71f161c..fb8134d55f7 100644
--- a/src/libs/Navigation/NavigationRoot.js
+++ b/src/libs/Navigation/NavigationRoot.js
@@ -30,14 +30,20 @@ class NavigationRoot extends Component {
}
const path = getPathFromState(state, linkingConfig.config);
- Log.info('Navigating to route', false, {path});
+
+ // Don't log the route transitions from OldDot because they contain authTokens
+ if (path.includes('/transition')) {
+ Log.info('Navigating from transition link from OldDot using short lived authToken');
+ } else {
+ Log.info('Navigating to route', false, {path});
+ }
setCurrentURL(path);
}
render() {
return (
}
+ fallback={}
onStateChange={this.parseAndStoreRoute}
ref={navigationRef}
linking={linkingConfig}
diff --git a/src/libs/Navigation/getPathName/index.js b/src/libs/Navigation/getPathName/index.js
deleted file mode 100644
index 161cece2a2e..00000000000
--- a/src/libs/Navigation/getPathName/index.js
+++ /dev/null
@@ -1,4 +0,0 @@
-export default (initialUrl) => {
- const initialURLObject = new URL(initialUrl);
- return initialURLObject.pathname;
-};
diff --git a/src/libs/Navigation/getPathName/index.native.js b/src/libs/Navigation/getPathName/index.native.js
deleted file mode 100644
index 94bc2b70e39..00000000000
--- a/src/libs/Navigation/getPathName/index.native.js
+++ /dev/null
@@ -1 +0,0 @@
-export default () => '';
diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js
index ee5aab1368c..a9a4fd1ceb9 100644
--- a/src/libs/Navigation/linkingConfig.js
+++ b/src/libs/Navigation/linkingConfig.js
@@ -25,13 +25,14 @@ export default {
},
},
- // Public Routes
+ // Main Routes
SetPassword: ROUTES.SET_PASSWORD_WITH_VALIDATE_CODE,
ValidateLogin: ROUTES.VALIDATE_LOGIN_WITH_VALIDATE_CODE,
[SCREENS.LOGIN_WITH_VALIDATE_CODE_NEW_WORKSPACE]: ROUTES.LOGIN_WITH_VALIDATE_CODE_NEW_WORKSPACE,
[SCREENS.LOGIN_WITH_VALIDATE_CODE_2FA_NEW_WORKSPACE]: ROUTES.LOGIN_WITH_VALIDATE_CODE_2FA_NEW_WORKSPACE,
[SCREENS.LOGIN_WITH_VALIDATE_CODE_WORKSPACE_CARD]: ROUTES.LOGIN_WITH_VALIDATE_CODE_WORKSPACE_CARD,
[SCREENS.LOGIN_WITH_VALIDATE_CODE_2FA_WORKSPACE_CARD]: ROUTES.LOGIN_WITH_VALIDATE_CODE_2FA_WORKSPACE_CARD,
+ [SCREENS.LOG_IN_WITH_SHORT_LIVED_TOKEN]: ROUTES.LOGIN_WITH_SHORT_LIVED_TOKEN,
// Modal Screens
Settings: {
@@ -55,6 +56,10 @@ export default {
path: ROUTES.SETTINGS_ADD_PAYPAL_ME,
exact: true,
},
+ Settings_Add_Debit_Card: {
+ path: ROUTES.SETTINGS_ADD_DEBIT_CARD,
+ exact: true,
+ },
Settings_Profile: {
path: ROUTES.SETTINGS_PROFILE,
exact: true,
@@ -70,6 +75,38 @@ export default {
Settings_Add_Secondary_Login: {
path: ROUTES.SETTINGS_ADD_LOGIN,
},
+ Workspace_Initial: {
+ path: ROUTES.WORKSPACE_INITIAL,
+ },
+ Workspace_Settings: {
+ path: ROUTES.WORKSPACE_SETTINGS,
+ },
+ Workspace_Card: {
+ path: ROUTES.WORKSPACE_CARD,
+ },
+ Workspace_Reimburse: {
+ path: ROUTES.WORKSPACE_REIMBURSE,
+ },
+ Workspace_Bills: {
+ path: ROUTES.WORKSPACE_BILLS,
+ },
+ Workspace_Invoices: {
+ path: ROUTES.WORKSPACE_INVOICES,
+ },
+ Workspace_Travel: {
+ path: ROUTES.WORKSPACE_TRAVEL,
+ },
+ Workspace_Members: {
+ path: ROUTES.WORKSPACE_MEMBERS,
+ },
+ Workspace_BankAccount: {
+ path: ROUTES.WORKSPACE_BANK_ACCOUNT,
+ exact: true,
+ },
+ ReimbursementAccount: {
+ path: ROUTES.BANK_ACCOUNT,
+ exact: true,
+ },
},
},
Report_Details: {
@@ -136,31 +173,11 @@ export default {
EnablePayments_Root: ROUTES.ENABLE_PAYMENTS,
},
},
- ReimbursementAccount: {
- screens: {
- ReimbursementAccount_Root: ROUTES.BANK_ACCOUNT,
- },
- },
WorkspaceInvite: {
screens: {
WorkspaceInvite_Root: ROUTES.WORKSPACE_INVITE,
},
},
-
- WorkspaceSettings: {
- path: ROUTES.WORKSPACE,
- screens: {
- WorkspaceCard: ROUTES.WORKSPACE_CARD,
- WorkspacePeople: ROUTES.WORKSPACE_PEOPLE,
- },
- },
-
- WorkspaceEditor: {
- screens: {
- WorkspaceEditor_Root: ROUTES.WORKSPACE_EDITOR,
- },
- },
-
RequestCall: {
screens: {
RequestCall_Root: ROUTES.REQUEST_CALL,
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index 1ad67c2236b..838257a8cdd 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -233,7 +233,7 @@ function createOption(personalDetailList, report, draftComments, {
return {
text,
alternateText,
- icons: report ? report.icons : [personalDetail.avatar],
+ icons: lodashGet(report, 'icons', [personalDetail.avatar]),
tooltipText,
participantsList: personalDetailList,
@@ -241,6 +241,8 @@ function createOption(personalDetailList, report, draftComments, {
// there isn't any one single login to refer to for a report.
login: !hasMultipleParticipants ? personalDetail.login : null,
reportID: report ? report.reportID : null,
+ phoneNumber: !hasMultipleParticipants ? personalDetail.phoneNumber : null,
+ payPalMeAddress: !hasMultipleParticipants ? personalDetail.payPalMeAddress : null,
isUnread: report ? report.unreadActionCount > 0 : null,
hasDraftComment: _.size(reportDraftComment) > 0,
keyForList: report ? String(report.reportID) : personalDetail.login,
diff --git a/src/libs/Permissions.js b/src/libs/Permissions.js
index bf1850cb9b8..1eede852be3 100644
--- a/src/libs/Permissions.js
+++ b/src/libs/Permissions.js
@@ -63,8 +63,18 @@ function canUseInternationalization(betas) {
* @param {Array} betas
* @returns {Boolean}
*/
+
+function canUseIOUSend(betas) {
+ return _.contains(betas, CONST.BETAS.IOU_SEND) || canUseAllBetas(betas);
+}
+
+/**
+ * @param {Array} betas
+ * @returns {Boolean}
+ */
+
function canUseWallet(betas) {
- return _.contains(betas, CONST.BETAS.BETA_EXPENSIFY_WALLET || canUseAllBetas(betas));
+ return _.contains(betas, CONST.BETAS.BETA_EXPENSIFY_WALLET) || canUseAllBetas(betas);
}
export default {
@@ -74,5 +84,6 @@ export default {
canUseFreePlan,
canUseDefaultRooms,
canUseInternationalization,
+ canUseIOUSend,
canUseWallet,
};
diff --git a/src/libs/ReimbursementAccountUtils.js b/src/libs/ReimbursementAccountUtils.js
index 8f411e85fe4..e655da49363 100644
--- a/src/libs/ReimbursementAccountUtils.js
+++ b/src/libs/ReimbursementAccountUtils.js
@@ -1,4 +1,6 @@
import lodashGet from 'lodash/get';
+import lodashUnset from 'lodash/unset';
+import lodashCloneDeep from 'lodash/cloneDeep';
import {setBankAccountFormValidationErrors} from './actions/BankAccounts';
/**
@@ -25,18 +27,18 @@ function getErrors(props) {
/**
* @param {Object} props
- * @param {String} inputKey
+ * @param {String} path
*/
-function clearError(props, inputKey) {
+function clearError(props, path) {
const errors = getErrors(props);
- if (!errors[inputKey]) {
- // No error found for this inputKey
+ if (!lodashGet(errors, path, false)) {
+ // No error found for this path
return;
}
- // Clear the existing error for this inputKey
- const newErrors = {...errors};
- delete newErrors[inputKey];
+ // Clear the existing errors
+ const newErrors = lodashCloneDeep(errors);
+ lodashUnset(newErrors, path);
setBankAccountFormValidationErrors(newErrors);
}
diff --git a/src/libs/SignoutManager.js b/src/libs/SignoutManager.js
new file mode 100644
index 00000000000..065d8249cd1
--- /dev/null
+++ b/src/libs/SignoutManager.js
@@ -0,0 +1,38 @@
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '../ONYXKEYS';
+
+let signoutCallback = () => {};
+let errorMessage = '';
+let shouldSignOut = false;
+Onyx.connect({
+ key: ONYXKEYS.SHOULD_SIGN_OUT,
+ callback: (val) => {
+ if (!shouldSignOut && val) {
+ signoutCallback(errorMessage);
+ errorMessage = '';
+ Onyx.set(ONYXKEYS.SHOULD_SIGN_OUT, false);
+ }
+
+ shouldSignOut = val;
+ },
+});
+
+/**
+ * @param {Function} callback
+ */
+function registerSignoutCallback(callback) {
+ signoutCallback = callback;
+}
+
+/**
+ * @param {String} message
+ */
+function signOut(message) {
+ errorMessage = message;
+ Onyx.set(ONYXKEYS.SHOULD_SIGN_OUT, true);
+}
+
+export default {
+ signOut,
+ registerSignoutCallback,
+};
diff --git a/src/libs/UnreadIndicatorUpdater/index.js b/src/libs/UnreadIndicatorUpdater/index.js
index 00547ec957d..aca435ba511 100644
--- a/src/libs/UnreadIndicatorUpdater/index.js
+++ b/src/libs/UnreadIndicatorUpdater/index.js
@@ -12,14 +12,14 @@ const unreadActionCounts = {};
* and Mac OS or iOS dock icon with an unread indicator.
*/
const throttledUpdatePageTitleAndUnreadCount = _.throttle(() => {
- // If all of our nonzero unread action counts show -1, update the unread count to be -1 as well so we don't show a
- // number in the indicator badge.
- if (_.every(unreadActionCounts, count => count < 1) && _.some(unreadActionCounts, count => count === -1)) {
- updateUnread(-1);
+ const totalCount = _.reduce(unreadActionCounts, (total, reportCount) => total + Math.max(reportCount, 0), 0);
+
+ // When we don't have an exact count we just let the user know there's something new
+ if (totalCount === 0 && _.some(unreadActionCounts, count => count === -1)) {
+ updateUnread(1);
return;
}
- const totalCount = _.reduce(unreadActionCounts, (total, reportCount) => total + Math.max(reportCount, 0), 0);
updateUnread(totalCount);
}, 1000, {leading: false});
@@ -41,7 +41,7 @@ function listenForReportChanges() {
return;
}
- // An unreadActionCount of -1 signifies that we should show a badge icon with no number
+ // An unreadActionCount of -1 signifies that we're not interested in showing exact count
if (report.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.DAILY
&& report.unreadActionCount > 0) {
unreadActionCounts[report.reportID] = -1;
diff --git a/src/libs/UnreadIndicatorUpdater/updateUnread/index.ios.js b/src/libs/UnreadIndicatorUpdater/updateUnread/index.ios.js
index 16f102622c1..b9b62a42ea6 100644
--- a/src/libs/UnreadIndicatorUpdater/updateUnread/index.ios.js
+++ b/src/libs/UnreadIndicatorUpdater/updateUnread/index.ios.js
@@ -7,11 +7,7 @@ import {UrbanAirship} from 'urbanairship-react-native';
* @param {Number} totalCount
*/
function updateUnread(totalCount) {
- if (totalCount === -1) {
- UrbanAirship.setBadgeNumber(1);
- } else {
- UrbanAirship.setBadgeNumber(totalCount);
- }
+ UrbanAirship.setBadgeNumber(totalCount);
}
export default updateUnread;
diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.js
index 7174d276f5a..4b1223e25b7 100644
--- a/src/libs/ValidationUtils.js
+++ b/src/libs/ValidationUtils.js
@@ -1,8 +1,29 @@
import moment from 'moment';
import _ from 'underscore';
import CONST from '../CONST';
-import {showBankAccountFormValidationError, showBankAccountErrorModal} from './actions/BankAccounts';
-import {translateLocal} from './translate';
+
+
+/**
+ * Implements the Luhn Algorithm, a checksum formula used to validate credit card
+ * numbers.
+ *
+ * @param {String} val
+ * @returns {Boolean}
+ */
+function validateCardNumber(val) {
+ let sum = 0;
+ for (let i = 0; i < val.length; i++) {
+ let intVal = parseInt(val.substr(i, 1), 10);
+ if (i % 2 === 0) {
+ intVal *= 2;
+ if (intVal > 9) {
+ intVal = 1 + (intVal % 10);
+ }
+ }
+ sum += intVal;
+ }
+ return (sum % 10) === 0;
+}
/**
* Validating that this is a valid address (PO boxes are not allowed)
@@ -18,6 +39,23 @@ function isValidAddress(value) {
return !CONST.REGEX.PO_BOX.test(value);
}
+/**
+ * Validate date fields
+ *
+ * @param {String|Date} date
+ * @returns {Boolean} true if valid
+ */
+function isValidDate(date) {
+ if (!date) {
+ return false;
+ }
+
+ const pastDate = moment().subtract(1000, 'years');
+ const futureDate = moment().add(1000, 'years');
+ const testDate = moment(date);
+ return testDate.isValid() && testDate.isBetween(pastDate, futureDate);
+}
+
/**
* Used to validate a value that is "required".
*
@@ -28,6 +66,9 @@ function isRequiredFulfilled(value) {
if (_.isString(value)) {
return !_.isEmpty(value.trim());
}
+ if (_.isDate(value)) {
+ return isValidDate(value);
+ }
if (_.isArray(value) || _.isObject(value)) {
return !_.isEmpty(value);
}
@@ -35,13 +76,47 @@ function isRequiredFulfilled(value) {
}
/**
- * Validate date fields
+ * Validates that this is a valid expiration date
+ * in the MM/YY or MM/YYYY format
*
- * @param {String} date
- * @returns {Boolean} true if valid
+ * @param {String} string
+ * @returns {Boolean}
*/
-function isValidDate(date) {
- return moment(date).isValid();
+function isValidExpirationDate(string) {
+ return CONST.REGEX.CARD_EXPIRATION_DATE.test(string);
+}
+
+/**
+ * Validates that this is a valid security code
+ * in the XXX or XXXX format.
+ *
+ * @param {String} string
+ * @returns {Boolean}
+ */
+function isValidSecurityCode(string) {
+ return CONST.REGEX.CARD_SECURITY_CODE.test(string);
+}
+
+/**
+ * Validates a debit card number (15 or 16 digits).
+ *
+ * @param {String} string
+ * @returns {Boolean}
+ */
+function isValidDebitCard(string) {
+ if (!CONST.REGEX.CARD_NUMBER.test(string)) {
+ return false;
+ }
+
+ return validateCardNumber(string);
+}
+
+/**
+ * @param {String} code
+ * @returns {Boolean}
+ */
+function isValidIndustryCode(code) {
+ return CONST.REGEX.INDUSTRY_CODE.test(code);
}
/**
@@ -61,68 +136,94 @@ function isValidSSNLastFour(ssnLast4) {
}
/**
+ * Validate that "date" is between 18 and 150 years in the past
*
* @param {String} date
* @returns {Boolean}
*/
-function isValidAge(date) {
- return moment().diff(moment(date), 'years') >= 18;
+function meetsAgeRequirements(date) {
+ const eighteenYearsAgo = moment().subtract(18, 'years');
+ const oneHundredFiftyYearsAgo = moment().subtract(150, 'years');
+ const testDate = moment(date);
+ return testDate.isValid() && testDate.isBetween(oneHundredFiftyYearsAgo, eighteenYearsAgo);
}
/**
- * @param {Object} identity
+ *
+ * @param {String} phoneNumber
* @returns {Boolean}
*/
-function isValidIdentity(identity) {
- if (!isValidAddress(identity.street)) {
- showBankAccountFormValidationError(translateLocal('bankAccount.error.address'));
- showBankAccountErrorModal();
- return false;
- }
+function isValidPhoneWithSpecialChars(phoneNumber) {
+ return CONST.REGEX.PHONE_WITH_SPECIAL_CHARS.test(phoneNumber) && phoneNumber.length <= CONST.PHONE_MAX_LENGTH;
+}
- if (identity.state === '') {
- showBankAccountFormValidationError(translateLocal('bankAccount.error.addressState'));
- showBankAccountErrorModal();
- return false;
- }
+/**
+ * @param {String} url
+ * @returns {Boolean}
+ */
+function isValidURL(url) {
+ return CONST.REGEX.HYPERLINK.test(url);
+}
- if (identity.city === '') {
- showBankAccountFormValidationError(translateLocal('bankAccount.error.addressCity'));
- showBankAccountErrorModal();
- return false;
+/**
+ * @param {Object} identity
+ * @returns {Object}
+ */
+function validateIdentity(identity) {
+ const requiredFields = ['firstName', 'lastName', 'street', 'city', 'zipCode', 'state', 'ssnLast4', 'dob'];
+ const errors = {};
+
+ // Check that all required fields are filled
+ _.each(requiredFields, (fieldName) => {
+ if (isRequiredFulfilled(identity[fieldName])) {
+ return;
+ }
+ errors[fieldName] = true;
+ });
+
+ if (!isValidAddress(identity.street)) {
+ errors.street = true;
}
if (!isValidZipCode(identity.zipCode)) {
- showBankAccountFormValidationError(translateLocal('bankAccount.error.zipCode'));
- showBankAccountErrorModal();
- return false;
+ errors.zipCode = true;
}
+ // dob field has multiple validations/errors, we are handling it temporarily like this.
if (!isValidDate(identity.dob)) {
- showBankAccountFormValidationError(translateLocal('bankAccount.error.dob'));
- showBankAccountErrorModal();
- return false;
- }
-
- if (!isValidAge(identity.dob)) {
- showBankAccountFormValidationError(translateLocal('bankAccount.error.age'));
- showBankAccountErrorModal();
- return false;
+ errors.dob = true;
+ } else if (!meetsAgeRequirements(identity.dob)) {
+ errors.dobAge = true;
}
if (!isValidSSNLastFour(identity.ssnLast4)) {
- showBankAccountFormValidationError(translateLocal('bankAccount.error.ssnLast4'));
- showBankAccountErrorModal();
- return false;
+ errors.ssnLast4 = true;
}
- return true;
+ return errors;
+}
+
+/**
+ * @param {String} phoneNumber
+ * @returns {Boolean}
+ */
+function isValidUSPhone(phoneNumber) {
+ // Remove alphanumeric characters and validate that this is in fact a phone number
+ return CONST.REGEX.PHONE_E164_PLUS.test(phoneNumber.replace(CONST.REGEX.NON_ALPHA_NUMERIC, '')) && CONST.REGEX.US_PHONE.test(phoneNumber);
}
export {
+ meetsAgeRequirements,
isValidAddress,
isValidDate,
- isValidIdentity,
+ isValidSecurityCode,
+ isValidExpirationDate,
+ isValidDebitCard,
+ isValidIndustryCode,
isValidZipCode,
isRequiredFulfilled,
+ isValidPhoneWithSpecialChars,
+ isValidUSPhone,
+ isValidURL,
+ validateIdentity,
};
diff --git a/src/libs/actions/App.js b/src/libs/actions/App.js
index c7ac52b5134..dc782eb1165 100644
--- a/src/libs/actions/App.js
+++ b/src/libs/actions/App.js
@@ -1,11 +1,10 @@
-import {AppState, Linking} from 'react-native';
+import {AppState} from 'react-native';
import Onyx from 'react-native-onyx';
import lodashGet from 'lodash/get';
import ONYXKEYS from '../../ONYXKEYS';
import * as API from '../API';
import CONST from '../../CONST';
import Log from '../Log';
-import CONFIG from '../../CONFIG';
import Performance from '../Performance';
import Timing from './Timing';
@@ -41,20 +40,6 @@ function setLocale(locale) {
Onyx.merge(ONYXKEYS.NVP_PREFERRED_LOCALE, locale);
}
-/**
- * This links to a page in e.com ensuring the user is logged in.
- * It does so by getting a validate code and redirecting to the validate URL with exitTo set to the URL
- * we want to visit
- * @param {string} url relative URL starting with `/` to open in expensify.com
- */
-function openSignedInLink(url = '') {
- API.GetAccountValidateCode().then((response) => {
- const exitToURL = url ? `?exitTo=${url}` : '';
- const validateCodeUrl = `v/${currentUserAccountID}/${response.validateCode}${exitToURL}`;
- Linking.openURL(CONFIG.EXPENSIFY.URL_EXPENSIFY_COM + validateCodeUrl);
- });
-}
-
function setSidebarLoaded() {
if (isSidebarLoaded) {
return;
@@ -77,6 +62,5 @@ AppState.addEventListener('change', (nextAppState) => {
export {
setCurrentURL,
setLocale,
- openSignedInLink,
setSidebarLoaded,
};
diff --git a/src/libs/actions/BankAccounts.js b/src/libs/actions/BankAccounts.js
index 37e32c27ba2..ca64aac7cd2 100644
--- a/src/libs/actions/BankAccounts.js
+++ b/src/libs/actions/BankAccounts.js
@@ -9,7 +9,6 @@ import * as API from '../API';
import BankAccount from '../models/BankAccount';
import Growl from '../Growl';
import {translateLocal} from '../translate';
-import Navigation from '../Navigation/Navigation';
/**
* List of bank accounts. This data should not be stored in Onyx since it contains unmasked PANs.
@@ -64,7 +63,7 @@ function goToWithdrawalAccountSetupStep(stepID, achData) {
if (!newACHData.useOnfido && stepID === CONST.BANK_ACCOUNT.STEP.REQUESTOR) {
delete newACHData.questions;
delete newACHData.answers;
- if (lodashHas(achData, CONST.BANK_ACCOUNT.VERIFICATIONS.EXTERNAL_API_RESPONSES)) {
+ if (lodashHas(newACHData, CONST.BANK_ACCOUNT.VERIFICATIONS.EXTERNAL_API_RESPONSES)) {
delete newACHData.verifications.externalApiResponses.requestorIdentityID;
delete newACHData.verifications.externalApiResponses.requestorIdentityKBA;
}
@@ -72,7 +71,7 @@ function goToWithdrawalAccountSetupStep(stepID, achData) {
// When going back to the BankAccountStep from the Company Step, show the manual form instead of Plaid
if (newACHData.currentStep === CONST.BANK_ACCOUNT.STEP.COMPANY && stepID === CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT) {
- newACHData.subStep = 'manual';
+ newACHData.subStep = CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL;
}
Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {...newACHData, ...achData, currentStep: stepID}});
@@ -115,6 +114,7 @@ function getPlaidBankAccounts(publicToken, bank) {
...account,
accountNumber: Str.maskPAN(account.accountNumber),
})),
+ bankName,
});
});
}
@@ -334,17 +334,21 @@ function fetchUserWallet() {
* Fetch the bank account currently being set up by the user for the free plan if it exists.
*
* @param {String} [stepToOpen]
+ * @param {String} [localBankAccountState]
*/
-function fetchFreePlanVerifiedBankAccount(stepToOpen) {
+function fetchFreePlanVerifiedBankAccount(stepToOpen, localBankAccountState) {
+ // Remember which account BankAccountStep subStep the user had before so we can set it later
+ const subStep = lodashGet(reimbursementAccountInSetup, 'subStep', '');
+ const initialData = {loading: true, error: ''};
+
+ // Some UI needs to know the bank account state during the loading process, so we are keeping it in Onyx if passed
+ if (localBankAccountState) {
+ initialData.achData = {state: localBankAccountState};
+ }
+
// We are using set here since we will rely on data from the server (not local data) to populate the VBA flow
// and determine which step to navigate to.
- Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {
- loading: true,
- error: '',
-
- // We temporarily keep the achData state to prevent UI changes while fetching.
- achData: {state: lodashGet(reimbursementAccountInSetup, 'state', '')},
- });
+ Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, initialData);
let bankAccountID;
API.Get({
@@ -385,21 +389,25 @@ function fetchFreePlanVerifiedBankAccount(stepToOpen) {
// If the user is already setting up a bank account we will continue the flow for them
let currentStep = reimbursementAccountInSetup.currentStep;
const achData = bankAccount ? bankAccount.toACHData() : {};
+ if (!stepToOpen && achData.currentStep) {
+ // eslint-disable-next-line no-use-before-define
+ currentStep = getNextStepToComplete(achData);
+ }
+
achData.useOnfido = true;
achData.policyID = reimbursementAccountWorkspaceID || '';
achData.isInSetup = !bankAccount || bankAccount.isInSetup();
achData.bankAccountInReview = bankAccount && bankAccount.isVerifying();
achData.domainLimit = 0;
- // Adding a default empty state to make sure we override the temporary one we are keeping
- // for UI purposes. This covers an edge case in which a user deleted their bank account,
- // but would still see Finish Setup in the UI, instead of Get Started.
- achData.state = lodashGet(achData, 'state', '');
-
// If the bank account has already been created in the db and is not yet open
- // let's show the manual form with the previously added values
- achData.subStep = bankAccount && bankAccount.isInSetup()
- && CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL;
+ // let's show the manual form with the previously added values. Otherwise, we will
+ // make the subStep the previous value.
+ if (bankAccount && bankAccount.isInSetup()) {
+ achData.subStep = CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL;
+ } else {
+ achData.subStep = subStep;
+ }
// If we're not in setup, it means we already have a withdrawal account
// and we're upgrading it to a business bank account. So let the user
@@ -515,16 +523,29 @@ function getIndexByStepID(stepID) {
/**
* Get next step ID
+ * @param {String} [stepID]
* @return {String}
*/
-function getNextStepID() {
+function getNextStepID(stepID) {
const nextStepIndex = Math.min(
- getIndexByStepID(reimbursementAccountInSetup.currentStep) + 1,
+ getIndexByStepID(stepID || reimbursementAccountInSetup.currentStep) + 1,
WITHDRAWAL_ACCOUNT_STEPS.length - 1,
);
return lodashGet(WITHDRAWAL_ACCOUNT_STEPS, [nextStepIndex, 'id'], CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT);
}
+/**
+ * @param {Object} achData
+ * @returns {String}
+ */
+function getNextStepToComplete(achData) {
+ if (achData.currentStep === CONST.BANK_ACCOUNT.STEP.REQUESTOR && !achData.isOnfidoSetupComplete) {
+ return CONST.BANK_ACCOUNT.STEP.REQUESTOR;
+ }
+
+ return getNextStepID(achData.currentStep);
+}
+
/**
* @private
* @param {Number} bankAccountID
@@ -537,9 +558,10 @@ function setFreePlanVerifiedBankAccountID(bankAccountID) {
* Show error modal and optionally a specific error message
*
* @param {String} errorModalMessage The error message to be displayed in the modal's body.
+ * @param {Boolean} isErrorModalMessageHtml if @errorModalMessage is in html format or not
*/
-function showBankAccountErrorModal(errorModalMessage = null) {
- Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {isErrorModalVisible: true, errorModalMessage});
+function showBankAccountErrorModal(errorModalMessage = null, isErrorModalMessageHtml = false) {
+ Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {errorModalMessage, isErrorModalMessageHtml});
}
/**
@@ -552,7 +574,6 @@ function validateBankAccount(bankAccountID, validateCode) {
API.BankAccount_Validate({bankAccountID, validateCode})
.then((response) => {
if (response.jsonCode === 200) {
- Growl.show('Bank Account successfully validated!', CONST.GROWL.SUCCESS, 5000);
Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, null);
API.User_IsUsingExpensifyCard()
.then(({isUsingExpensifyCard}) => {
@@ -562,12 +583,7 @@ function validateBankAccount(bankAccountID, validateCode) {
achData: {state: BankAccount.STATE.OPEN},
};
- if (isUsingExpensifyCard) {
- Navigation.dismissModal();
- } else {
- reimbursementAccount.achData.currentStep = CONST.BANK_ACCOUNT.STEP.ENABLE;
- }
-
+ reimbursementAccount.achData.currentStep = CONST.BANK_ACCOUNT.STEP.ENABLE;
Onyx.merge(ONYXKEYS.USER, {isUsingExpensifyCard});
Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, reimbursementAccount);
});
@@ -580,19 +596,19 @@ function validateBankAccount(bankAccountID, validateCode) {
return;
}
- // We are generically showing any backend errors that might pop up in the validate step
+ // If the validation amounts entered were incorrect, show specific error
+ if (response.message === CONST.BANK_ACCOUNT.ERROR.INCORRECT_VALIDATION_AMOUNTS) {
+ showBankAccountErrorModal(translateLocal('bankAccount.error.validationAmounts'));
+ Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false});
+ return;
+ }
+
+ // We are generically showing any other backend errors that might pop up in the validate step
showBankAccountErrorModal(response.message);
Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false});
});
}
-/**
- * Hide error modal
- */
-function hideBankAccountErrorModal() {
- Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {isErrorModalVisible: false});
-}
-
/**
* Set the current fields with errors.
*
@@ -613,6 +629,15 @@ function showBankAccountFormValidationError(error) {
Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {error});
}
+/**
+ * Set the current sub step in first step of adding withdrawal bank account
+ *
+ * @param {String} subStep - One of {CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL, CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID, null}
+ */
+function setBankAccountSubStep(subStep) {
+ Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {subStep}});
+}
+
/**
* Create or update the bank account in db with the updated data.
*
@@ -652,21 +677,12 @@ function setupWithdrawalAccount(data) {
}
API.BankAccount_SetupWithdrawal(newACHData)
- /* eslint-disable arrow-body-style */
- .then((response) => {
- // Without this block, we can call merge again with the achData before this merge finishes, resulting in
- // the original achData overwriting the data we're trying to set here. With this block, we ensure that the
- // newACHData is set in Onyx before we call merge on the reimbursementAccount key again.
- return Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {
- loading: false,
- achData: {...newACHData},
- })
- .then(() => Promise.resolve(response));
- })
.then((response) => {
+ Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {...newACHData}});
const currentStep = newACHData.currentStep;
let achData = lodashGet(response, 'achData', {});
let error = lodashGet(achData, CONST.BANK_ACCOUNT.VERIFICATIONS.ERROR_MESSAGE);
+ let isErrorHTML = false;
const errors = {};
if (response.jsonCode === 200 && !error) {
@@ -694,6 +710,7 @@ function setupWithdrawalAccount(data) {
// Requestor Step still needs to run Onfido
achData.sdkToken = sdkToken;
goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, achData);
+ Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false});
return;
}
} else if (requestorResponse) {
@@ -711,6 +728,7 @@ function setupWithdrawalAccount(data) {
if (!_.isEmpty(questions)) {
achData.questions = questions;
goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, achData);
+ Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false});
return;
}
}
@@ -732,6 +750,7 @@ function setupWithdrawalAccount(data) {
|| achData.state === BankAccount.STATE.VERIFYING;
goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.VALIDATION, achData);
+ Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false});
});
return;
}
@@ -745,7 +764,9 @@ function setupWithdrawalAccount(data) {
}
} else {
if (response.jsonCode === 666 || response.jsonCode === 404) {
- error = response.message;
+ // Since these specific responses can have an error message in html format with richer content, give priority to the html error.
+ error = response.htmlMessage || response.message;
+ isErrorHTML = Boolean(response.htmlMessage);
}
if (response.jsonCode === 402) {
@@ -773,8 +794,9 @@ function setupWithdrawalAccount(data) {
}
if (error) {
showBankAccountFormValidationError(error);
- showBankAccountErrorModal(error);
+ showBankAccountErrorModal(error, isErrorHTML);
}
+ Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false});
})
.catch((response) => {
Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false, achData: {...newACHData}});
@@ -811,10 +833,10 @@ export {
setupWithdrawalAccount,
validateBankAccount,
hideBankAccountErrors,
- hideBankAccountErrorModal,
showBankAccountErrorModal,
showBankAccountFormValidationError,
setBankAccountFormValidationErrors,
setWorkspaceIDForReimbursementAccount,
+ setBankAccountSubStep,
updateReimbursementAccountDraft,
};
diff --git a/src/libs/actions/Link.js b/src/libs/actions/Link.js
new file mode 100644
index 00000000000..2ccd29510e0
--- /dev/null
+++ b/src/libs/actions/Link.js
@@ -0,0 +1,57 @@
+import Onyx from 'react-native-onyx';
+import lodashGet from 'lodash/get';
+import {Linking} from 'react-native';
+import ONYXKEYS from '../../ONYXKEYS';
+import Growl from '../Growl';
+import {translateLocal} from '../translate';
+import CONST from '../../CONST';
+import * as API from '../API';
+import CONFIG from '../../CONFIG';
+import asyncOpenURL from '../asyncOpenURL';
+
+let isNetworkOffline = false;
+Onyx.connect({
+ key: ONYXKEYS.NETWORK,
+ callback: val => isNetworkOffline = lodashGet(val, 'isOffline', false),
+});
+
+let currentUserEmail;
+Onyx.connect({
+ key: ONYXKEYS.SESSION,
+ callback: val => currentUserEmail = lodashGet(val, 'email', ''),
+});
+
+/**
+ * @returns {Boolean}
+ */
+function showGrowlIfOffline() {
+ if (isNetworkOffline) {
+ Growl.show(translateLocal('session.offlineMessageRetry'), CONST.GROWL.WARNING);
+ }
+ return isNetworkOffline;
+}
+
+/**
+ * @param {String} url
+ */
+function openOldDotLink(url) {
+ if (!showGrowlIfOffline()) {
+ // eslint-disable-next-line max-len
+ const buildOldDotURL = ({shortLivedAuthToken}) => `${CONFIG.EXPENSIFY.URL_EXPENSIFY_COM}${url}${url.indexOf('?') === -1 ? '?' : '&'}authToken=${shortLivedAuthToken}&email=${encodeURIComponent(currentUserEmail)}`;
+ asyncOpenURL(API.GetShortLivedAuthToken(), buildOldDotURL);
+ }
+}
+
+/**
+ * @param {String} url
+ */
+function openExternalLink(url) {
+ if (!showGrowlIfOffline()) {
+ Linking.openURL(url);
+ }
+}
+
+export {
+ openOldDotLink,
+ openExternalLink,
+};
diff --git a/src/libs/actions/PaymentMethods.js b/src/libs/actions/PaymentMethods.js
index 15ee7efcec5..d9467327244 100644
--- a/src/libs/actions/PaymentMethods.js
+++ b/src/libs/actions/PaymentMethods.js
@@ -3,6 +3,11 @@ import Onyx from 'react-native-onyx';
import ONYXKEYS from '../../ONYXKEYS';
import * as API from '../API';
import CONST from '../../CONST';
+import ROUTES from '../../ROUTES';
+import Growl from '../Growl';
+import {translateLocal} from '../translate';
+import Navigation from '../Navigation/Navigation';
+import {maskCardNumber} from '../cardUtils';
/**
* Calls the API to get the user's bankAccountList, cardList, wallet, and payPalMe
@@ -10,6 +15,7 @@ import CONST from '../../CONST';
* @returns {Promise}
*/
function getPaymentMethods() {
+ Onyx.set(ONYXKEYS.IS_LOADING_PAYMENT_METHODS, true);
return API.Get({
returnValueList: 'bankAccountList, fundList, userWallet, nameValuePairs',
name: 'paypalMeAddress',
@@ -19,6 +25,7 @@ function getPaymentMethods() {
})
.then((response) => {
Onyx.multiSet({
+ [ONYXKEYS.IS_LOADING_PAYMENT_METHODS]: false,
[ONYXKEYS.USER_WALLET]: lodashGet(response, 'userWallet', {}),
[ONYXKEYS.BANK_ACCOUNT_LIST]: lodashGet(response, 'bankAccountList', []),
[ONYXKEYS.CARD_LIST]: lodashGet(response, 'fundList', []),
@@ -28,4 +35,50 @@ function getPaymentMethods() {
});
}
-export default getPaymentMethods;
+/**
+ * Calls the API to add a new card.
+ *
+ * @param {Object} params
+ */
+function addBillingCard(params) {
+ const cardYear = params.expirationDate.substr(3);
+ const cardMonth = params.expirationDate.substr(0, 2);
+
+ API.AddBillingCard({
+ cardNumber: params.cardNumber,
+ cardYear,
+ cardMonth,
+ cardCVV: params.securityCode,
+ addressName: params.nameOnCard,
+ addressZip: params.zipCode,
+ currency: CONST.CURRENCY.USD,
+ }).then(((response) => {
+ if (response.jsonCode === 200) {
+ const cardObject = {
+ additionalData: {
+ isBillingCard: false,
+ isP2PDebitCard: true,
+ },
+ addressName: params.nameOnCard,
+ addressState: params.selectedState,
+ addressStreet: params.billingAddress,
+ addressZip: params.zipCode,
+ cardMonth,
+ cardNumber: maskCardNumber(params.cardNumber),
+ cardYear,
+ currency: 'USD',
+ fundID: lodashGet(response, 'fundID', ''),
+ };
+ Onyx.merge(ONYXKEYS.CARD_LIST, [cardObject]);
+ Growl.show(translateLocal('addDebitCardPage.growlMessageOnSave'), CONST.GROWL.SUCCESS, 3000);
+ Navigation.navigate(ROUTES.SETTINGS_PAYMENTS);
+ } else {
+ Growl.error(translateLocal('addDebitCardPage.error.genericFailureMessage', 3000));
+ }
+ }));
+}
+
+export {
+ getPaymentMethods,
+ addBillingCard,
+};
diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.js
index 39b6ba2f812..91f5c9b4e83 100644
--- a/src/libs/actions/PersonalDetails.js
+++ b/src/libs/actions/PersonalDetails.js
@@ -79,6 +79,8 @@ function formatPersonalDetails(personalDetailsList) {
const timezone = lodashGet(personalDetailsResponse, 'timeZone', CONST.DEFAULT_TIME_ZONE);
const firstName = lodashGet(personalDetailsResponse, 'firstName', '');
const lastName = lodashGet(personalDetailsResponse, 'lastName', '');
+ const payPalMeAddress = lodashGet(personalDetailsResponse, 'expensify_payPalMeAddress', '');
+ const phoneNumber = lodashGet(personalDetailsResponse, 'phoneNumber', '');
return {
...finalObject,
@@ -90,6 +92,8 @@ function formatPersonalDetails(personalDetailsList) {
lastName,
pronouns,
timezone,
+ payPalMeAddress,
+ phoneNumber,
},
};
}, {});
diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js
index acb4d445492..70c40d7cf46 100644
--- a/src/libs/actions/Policy.js
+++ b/src/libs/actions/Policy.js
@@ -22,7 +22,23 @@ Onyx.connect({
});
/**
- * Takes a full policy summary that is returned from the policySummaryList and simplifies it so we are only storing
+ * Simplifies the employeeList response into an object containing an array of emails
+ *
+ * @param {Object} employeeList
+ * @returns {Array}
+ */
+function getSimplifiedEmployeeList(employeeList) {
+ const employeeListEmails = _.chain(employeeList)
+ .pluck('email')
+ .flatten()
+ .unique()
+ .value();
+
+ return employeeListEmails;
+}
+
+/**
+ * Takes a full policy that is returned from the policyList and simplifies it so we are only storing
* the pieces of data that we need to in Onyx
*
* @param {Object} fullPolicy
@@ -30,6 +46,9 @@ Onyx.connect({
* @param {String} fullPolicy.name
* @param {String} fullPolicy.role
* @param {String} fullPolicy.type
+ * @param {String} fullPolicy.outputCurrency
+ * @param {Object} fullPolicy.value.employeeList
+ * @param {String} [fullPolicy.value.avatarURL]
* @returns {Object}
*/
function getSimplifiedPolicyObject(fullPolicy) {
@@ -38,23 +57,22 @@ function getSimplifiedPolicyObject(fullPolicy) {
name: fullPolicy.name,
role: fullPolicy.role,
type: fullPolicy.type,
+ owner: fullPolicy.owner,
+ outputCurrency: fullPolicy.outputCurrency,
+ employeeList: getSimplifiedEmployeeList(lodashGet(fullPolicy, 'value.employeeList')),
+ avatarURL: lodashGet(fullPolicy, 'value.avatarURL', ''),
};
}
/**
- * Simplifies the employeeList response into an object containing an array of emails
- *
- * @param {Object} employeeList
- * @returns {Array}
+ * @param {Array
- {
- if (error === translateLocal('bankAccount.error.dob') || error === translateLocal('bankAccount.error.age')) {
- hideBankAccountErrors();
- }
- onFieldChange('dob', val);
- }}
- errorText={error === translateLocal('bankAccount.error.dob') || error === translateLocal('bankAccount.error.age') ? error : ''}
+ onChange={value => onFieldChange('dob', value)}
+ errorText={dobErrorText}
/>
{
- if (error === translateLocal('bankAccount.error.ssnLast4')) {
- hideBankAccountErrors();
- }
- onFieldChange('ssnLast4', val);
- }}
- errorText={error === translateLocal('bankAccount.error.ssnLast4') ? error : ''}
+ onChangeText={value => onFieldChange('ssnLast4', value)}
+ errorText={errors.ssnLast4 ? translate('bankAccount.error.ssnLast4') : ''}
+ maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.SSN}
/>
- {
- if (error === translateLocal('bankAccount.error.address')) {
- hideBankAccountErrors();
- }
- onFieldChange('street', val);
- }}
- errorText={error === translateLocal('bankAccount.error.address') ? error : ''}
- />
- {translate('common.noPO')}
-
-
- {
- if (error === translateLocal('bankAccount.error.addressCity')) {
- hideBankAccountErrors();
- }
- onFieldChange('city', val);
- }}
- errorText={error === translateLocal('bankAccount.error.addressCity') ? error : ''}
- translateX={-14}
- />
-
-
- {
- if (error === translateLocal('bankAccount.error.addressState')) {
- hideBankAccountErrors();
- }
- onFieldChange('state', val);
- }}
- errorText={error === translateLocal('bankAccount.error.addressState') ? error : ''}
- hasError={error === translateLocal('bankAccount.error.addressState')}
- />
-
-
- {
- if (error === translateLocal('bankAccount.error.zipCode')) {
- hideBankAccountErrors();
- }
- onFieldChange('zipCode', val);
- }}
- errorText={error === translateLocal('bankAccount.error.zipCode') ? error : ''}
+ value={getFormattedAddressValue()}
+ onChangeText={(fieldName, value) => onFieldChange(fieldName, value)}
+ errorText={errors.street ? translate('bankAccount.error.addressStreet') : ''}
/>
);
diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountForm.js b/src/pages/ReimbursementAccount/ReimbursementAccountForm.js
index 347c032c45d..e3166916747 100644
--- a/src/pages/ReimbursementAccount/ReimbursementAccountForm.js
+++ b/src/pages/ReimbursementAccount/ReimbursementAccountForm.js
@@ -5,17 +5,13 @@ import PropTypes from 'prop-types';
import {ScrollView, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import TextLink from '../../components/TextLink';
-import Text from '../../components/Text';
-import Button from '../../components/Button';
import styles from '../../styles/styles';
import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
-import Icon from '../../components/Icon';
-import {Exclamation} from '../../components/Icon/Expensicons';
-import colors from '../../styles/colors';
import reimbursementAccountPropTypes from './reimbursementAccountPropTypes';
import compose from '../../libs/compose';
import ONYXKEYS from '../../ONYXKEYS';
+import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton';
+import CONST from '../../CONST';
const propTypes = {
/** ACH data for the withdrawal account actively being set up */
@@ -32,44 +28,6 @@ const defaultProps = {
};
class ReimbursementAccountForm extends React.Component {
- /**
- * @returns {React.Component|String}
- */
- getAlertPrompt() {
- let error = '';
-
- if (!_.isEmpty(this.props.reimbursementAccount.errorModalMessage)) {
- error = (
- {this.props.reimbursementAccount.errorModalMessage}
- );
- } else {
- error = (
- <>
-
- {`${this.props.translate('common.please')} `}
-
- {
- this.form.scrollTo({y: 0, animated: true});
- }}
- >
- {this.props.translate('bankAccount.error.fixTheErrors')}
-
-
- {` ${this.props.translate('bankAccount.error.inTheFormBeforeContinuing')}.`}
-
- >
- );
- }
-
- return (
-
- {error}
-
- );
- }
-
render() {
const isErrorVisible = _.size(lodashGet(this.props, 'reimbursementAccount.errors', {})) > 0
|| lodashGet(this.props, 'reimbursementAccount.errorModalMessage', '').length > 0
@@ -77,6 +35,12 @@ class ReimbursementAccountForm extends React.Component {
// @TODO once all validation errors show in multiples we can remove this check
|| lodashGet(this.props, 'reimbursementAccount.error', '').length > 0;
+ const currentStep = lodashGet(
+ this.props,
+ 'reimbursementAccount.achData.currentStep',
+ CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT,
+ );
+
return (
{this.props.children}
-
- {isErrorVisible && (
-
-
- {this.getAlertPrompt()}
-
- )}
-
-
+ {
+ this.form.scrollTo({y: 0, animated: true});
+ }}
+ message={this.props.reimbursementAccount.errorModalMessage}
+ isMessageHtml={this.props.reimbursementAccount.isErrorModalMessageHtml}
+ />
);
}
diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js
index 3adffd340a2..bacb0053b80 100644
--- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js
+++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js
@@ -1,4 +1,4 @@
-import moment from 'moment';
+import _ from 'underscore';
import lodashGet from 'lodash/get';
import React from 'react';
import {withOnyx} from 'react-native-onyx';
@@ -11,7 +11,7 @@ import {
hideBankAccountErrors,
} from '../../libs/actions/BankAccounts';
import ONYXKEYS from '../../ONYXKEYS';
-import VBALoadingIndicator from '../../components/VBALoadingIndicator';
+import ReimbursementAccountLoadingIndicator from '../../components/ReimbursementAccountLoadingIndicator';
import Permissions from '../../libs/Permissions';
import Navigation from '../../libs/Navigation/Navigation';
import CONST from '../../CONST';
@@ -71,7 +71,10 @@ const defaultProps = {
class ReimbursementAccountPage extends React.Component {
componentDidMount() {
// We can specify a step to navigate to by using route params when the component mounts.
- fetchFreePlanVerifiedBankAccount(this.getStepToOpenFromRouteParams());
+ const stepToOpen = this.getStepToOpenFromRouteParams();
+
+ // If we are trying to navigate to `/bank-account/new` and we already have a bank account then don't allow returning to `/new`
+ fetchFreePlanVerifiedBankAccount(stepToOpen !== CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT ? stepToOpen : '');
}
componentDidUpdate(prevProps) {
@@ -112,6 +115,8 @@ class ReimbursementAccountPage extends React.Component {
return CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT;
case 'validate':
return CONST.BANK_ACCOUNT.STEP.VALIDATION;
+ case 'enable':
+ return CONST.BANK_ACCOUNT.STEP.ENABLE;
default:
return '';
}
@@ -131,6 +136,8 @@ class ReimbursementAccountPage extends React.Component {
return 'contract';
case CONST.BANK_ACCOUNT.STEP.VALIDATION:
return 'validate';
+ case CONST.BANK_ACCOUNT.STEP.ENABLE:
+ return 'enable';
case CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT:
default:
return 'new';
@@ -144,8 +151,24 @@ class ReimbursementAccountPage extends React.Component {
return null;
}
+ // The SetupWithdrawalAccount flow allows us to continue the flow from various points depending on where the
+ // user left off. This view will refer to the achData as the single source of truth to determine which route to
+ // display. We can also specify a specific route to navigate to via route params when the component first
+ // mounts which will set the achData.currentStep after the account data is fetched and overwrite the logical
+ // next step.
+ const achData = lodashGet(this.props, 'reimbursementAccount.achData', {});
+ const currentStep = achData.currentStep || CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT;
if (this.props.reimbursementAccount.loading) {
- return ;
+ const isSubmittingVerificationsData = _.contains([
+ CONST.BANK_ACCOUNT.STEP.COMPANY,
+ CONST.BANK_ACCOUNT.STEP.REQUESTOR,
+ CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT,
+ ], currentStep);
+ return (
+
+ );
}
let errorComponent;
@@ -161,25 +184,20 @@ class ReimbursementAccountPage extends React.Component {
const throttledDate = lodashGet(this.props, 'reimbursementAccount.throttledDate');
if (throttledDate) {
- const throttledEnd = moment().add(24, 'hours');
- if (moment() < throttledEnd) {
- errorComponent = (
-
-
- {this.props.translate('bankAccount.hasBeenThrottledError', {
- fromNow: throttledEnd.fromNow(),
- })}
-
-
- );
- }
+ errorComponent = (
+
+
+ {this.props.translate('bankAccount.hasBeenThrottledError')}
+
+
+ );
}
if (errorComponent) {
return (
{errorComponent}
@@ -187,13 +205,6 @@ class ReimbursementAccountPage extends React.Component {
);
}
- // The SetupWithdrawalAccount flow allows us to continue the flow from various points depending on where the
- // user left off. This view will refer to the achData as the single source of truth to determine which route to
- // display. We can also specify a specific route to navigate to via route params when the component first
- // mounts which will set the achData.currentStep after the account data is fetched and overwrite the logical
- // next step.
- const achData = lodashGet(this.props, 'reimbursementAccount.achData', {});
- const currentStep = achData.currentStep || CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT;
return (
diff --git a/src/pages/ReimbursementAccount/RequestorStep.js b/src/pages/ReimbursementAccount/RequestorStep.js
index 8e933e0543d..413c14646ab 100644
--- a/src/pages/ReimbursementAccount/RequestorStep.js
+++ b/src/pages/ReimbursementAccount/RequestorStep.js
@@ -2,6 +2,8 @@ import React from 'react';
import lodashGet from 'lodash/get';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
+import _ from 'underscore';
+import moment from 'moment';
import styles from '../../styles/styles';
import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
@@ -11,21 +13,27 @@ import Navigation from '../../libs/Navigation/Navigation';
import CheckboxWithLabel from '../../components/CheckboxWithLabel';
import Text from '../../components/Text';
import {
+ showBankAccountErrorModal,
goToWithdrawalAccountSetupStep,
- hideBankAccountErrors,
+ setBankAccountFormValidationErrors,
setupWithdrawalAccount,
- showBankAccountErrorModal,
- showBankAccountFormValidationError,
updateReimbursementAccountDraft,
} from '../../libs/actions/BankAccounts';
import IdentityForm from './IdentityForm';
-import {isRequiredFulfilled, isValidIdentity} from '../../libs/ValidationUtils';
+import {isRequiredFulfilled, validateIdentity} from '../../libs/ValidationUtils';
import Onfido from '../../components/Onfido';
import compose from '../../libs/compose';
import ONYXKEYS from '../../ONYXKEYS';
-import {getDefaultStateForField} from '../../libs/ReimbursementAccountUtils';
+import {
+ getDefaultStateForField,
+ clearError,
+ getErrors,
+} from '../../libs/ReimbursementAccountUtils';
+import Log from '../../libs/Log';
+import Growl from '../../libs/Growl';
import reimbursementAccountPropTypes from './reimbursementAccountPropTypes';
import ReimbursementAccountForm from './ReimbursementAccountForm';
+import {openExternalLink} from '../../libs/actions/Link';
const propTypes = {
/** Bank account currently in setup */
@@ -39,6 +47,7 @@ class RequestorStep extends React.Component {
super(props);
this.submit = this.submit.bind(this);
+ this.clearErrorAndSetValue = this.clearErrorAndSetValue.bind(this);
this.state = {
firstName: getDefaultStateForField(props, 'firstName'),
@@ -54,64 +63,78 @@ class RequestorStep extends React.Component {
isOnfidoSetupComplete: lodashGet(props, ['achData', 'isOnfidoSetupComplete'], false),
};
+ // Required fields not validated by `validateIdentity`
this.requiredFields = [
'firstName',
'lastName',
- 'requestorAddressStreet',
- 'requestorAddressCity',
- 'requestorAddressZipCode',
- 'dob',
- 'ssnLast4',
- 'requestorAddressState',
+ 'isControllingOfficer',
];
+
+ // Map a field to the key of the error's translation
+ this.errorTranslationKeys = {
+ firstName: 'bankAccount.error.firstName',
+ lastName: 'bankAccount.error.lastName',
+ isControllingOfficer: 'requestorStep.isControllingOfficerError',
+ };
+
+ this.clearError = inputKey => clearError(this.props, inputKey);
+ this.getErrors = () => getErrors(this.props);
}
- onFieldChange(field, value) {
+ /**
+ * Clear the error associated to inputKey if found and store the inputKey new value in the state.
+ *
+ * @param {String} inputKey
+ * @param {String} value
+ */
+ clearErrorAndSetValue(inputKey, value) {
const renamedFields = {
street: 'requestorAddressStreet',
city: 'requestorAddressCity',
state: 'requestorAddressState',
zipCode: 'requestorAddressZipCode',
+ addressStreet: 'requestorAddressStreet',
+ addressCity: 'requestorAddressCity',
+ addressState: 'requestorAddressState',
+ addressZipCode: 'requestorAddressZipCode',
};
- const fieldName = lodashGet(renamedFields, field, field);
- const newState = {[fieldName]: value};
+ const renamedInputKey = lodashGet(renamedFields, inputKey, inputKey);
+ const newState = {[renamedInputKey]: value};
this.setState(newState);
updateReimbursementAccountDraft(newState);
+
+ // dob field has multiple validations/errors, we are handling it temporarily like this.
+ if (inputKey === 'dob') {
+ this.clearError('dobAge');
+ }
+ this.clearError(inputKey);
}
/**
* @returns {Boolean}
*/
validate() {
- if (!this.state.isControllingOfficer) {
- showBankAccountFormValidationError(this.props.translate('requestorStep.isControllingOfficerError'));
- showBankAccountErrorModal();
- return false;
- }
-
- if (!isValidIdentity({
+ const errors = validateIdentity({
+ firstName: this.state.firstName,
+ lastName: this.state.lastName,
street: this.state.requestorAddressStreet,
state: this.state.requestorAddressState,
city: this.state.requestorAddressCity,
zipCode: this.state.requestorAddressZipCode,
dob: this.state.dob,
ssnLast4: this.state.ssnLast4,
- })) {
- return false;
- }
+ });
- if (!isRequiredFulfilled(this.state.firstName)) {
- showBankAccountFormValidationError(this.props.translate('bankAccount.error.firstName'));
+ _.each(this.requiredFields, (inputKey) => {
+ if (!isRequiredFulfilled(this.state[inputKey])) {
+ errors[inputKey] = true;
+ }
+ });
+ if (_.size(errors)) {
+ setBankAccountFormValidationErrors(errors);
showBankAccountErrorModal();
return false;
}
-
- if (!isRequiredFulfilled(this.state.lastName)) {
- showBankAccountFormValidationError(this.props.translate('bankAccount.error.lastName'));
- showBankAccountErrorModal();
- return false;
- }
-
return true;
}
@@ -119,7 +142,13 @@ class RequestorStep extends React.Component {
if (!this.validate()) {
return;
}
- setupWithdrawalAccount({...this.state});
+
+ const payload = {
+ ...this.state,
+ dob: moment(this.state.dob).format(CONST.DATE.MOMENT_FORMAT_STRING),
+ };
+
+ setupWithdrawalAccount(payload);
}
render() {
@@ -127,6 +156,7 @@ class RequestorStep extends React.Component {
<>
goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.COMPANY)}
onCloseButtonPress={Navigation.dismissModal}
@@ -135,7 +165,14 @@ class RequestorStep extends React.Component {
{
- goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR);
+ // We're taking the user back to the company step. They will need to come back to the requestor step to make the Onfido flow appear again.
+ goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.COMPANY);
+ }}
+ onError={(error) => {
+ // In case of any unexpected error we log it to the server, show a growl, and return the user back to the company step so they can try again.
+ Log.hmmm('Onfido error in RequestorStep', {error});
+ Growl.error(this.props.translate('onfidoStep.genericError'), 10000);
+ goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.COMPANY);
}}
onSuccess={(onfidoData) => {
this.setState({
@@ -167,7 +204,7 @@ class RequestorStep extends React.Component {
this.onFieldChange(field, value)}
+ onFieldChange={this.clearErrorAndSetValue}
values={{
firstName: this.state.firstName,
lastName: this.state.lastName,
@@ -178,20 +215,17 @@ class RequestorStep extends React.Component {
dob: this.state.dob,
ssnLast4: this.state.ssnLast4,
}}
- error={this.props.reimbursementAccount.error}
+ errors={this.props.reimbursementAccount.errors}
/>
{
- if (this.props.reimbursementAccount.error === this.props.translate('requestorStep.isControllingOfficerError')) {
- hideBankAccountErrors();
- }
-
this.setState((prevState) => {
const newState = {isControllingOfficer: !prevState.isControllingOfficer};
updateReimbursementAccountDraft(newState);
return newState;
});
+ this.clearError('isControllingOfficer');
}}
LabelComponent={() => (
@@ -201,35 +235,34 @@ class RequestorStep extends React.Component {
)}
style={[styles.mt4]}
- hasError={this.props.reimbursementAccount.error === this.props.translate('requestorStep.isControllingOfficerError')}
- errorText={this.props.reimbursementAccount.error === this.props.translate('requestorStep.isControllingOfficerError')
- ? this.props.translate('requestorStep.isControllingOfficerError') : ''}
+ hasError={Boolean(this.getErrors().isControllingOfficer)}
+ errorText={this.getErrors().isControllingOfficer ? this.props.translate('requestorStep.isControllingOfficerError') : ''}
/>
-
- {this.props.translate('requestorStep.financialRegulations')}
-
{this.props.translate('requestorStep.onFidoConditions')}
- openExternalLink('https://onfido.com/facial-scan-policy-and-release/')}
+ style={[styles.textMicro, styles.link]}
+ accessibilityRole="link"
>
{`${this.props.translate('requestorStep.onFidoFacialScan')}`}
-
+
{', '}
- openExternalLink('https://onfido.com/privacy/')}
+ style={[styles.textMicro, styles.link]}
+ accessibilityRole="link"
>
{`${this.props.translate('common.privacyPolicy')}`}
-
+
{` ${this.props.translate('common.and')} `}
- openExternalLink('https://onfido.com/terms-of-service/')}
+ style={[styles.textMicro, styles.link]}
+ accessibilityRole="link"
>
{`${this.props.translate('common.termsOfService')}`}
-
+
)}
diff --git a/src/pages/ReimbursementAccount/ValidationStep.js b/src/pages/ReimbursementAccount/ValidationStep.js
index 64f5aaa48b7..1b0a108dd5b 100644
--- a/src/pages/ReimbursementAccount/ValidationStep.js
+++ b/src/pages/ReimbursementAccount/ValidationStep.js
@@ -10,7 +10,6 @@ import {
validateBankAccount, updateReimbursementAccountDraft, setBankAccountFormValidationErrors, showBankAccountErrorModal,
} from '../../libs/actions/BankAccounts';
import {navigateToConciergeChat} from '../../libs/actions/Report';
-import Button from '../../components/Button';
import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
import Navigation from '../../libs/Navigation/Navigation';
import ExpensiTextInput from '../../components/ExpensiTextInput';
@@ -24,6 +23,9 @@ import {isRequiredFulfilled} from '../../libs/ValidationUtils';
import EnableStep from './EnableStep';
import reimbursementAccountPropTypes from './reimbursementAccountPropTypes';
import ReimbursementAccountForm from './ReimbursementAccountForm';
+import {ChatBubble} from '../../components/Icon/Expensicons';
+import {ConciergeBlue} from '../../components/Icon/Illustrations';
+import WorkspaceSection from '../workspace/WorkspaceSection';
const propTypes = {
...withLocalizePropTypes,
@@ -149,9 +151,6 @@ class ValidationStep extends React.Component {
}
navigateToConcierge() {
- // There are two modals that must be dismissed before we can reveal the Concierge
- // chat underneath these screens
- Navigation.dismissModal();
Navigation.dismissModal();
navigateToConciergeChat();
}
@@ -165,11 +164,16 @@ class ValidationStep extends React.Component {
}
const maxAttemptsReached = lodashGet(this.props, 'reimbursementAccount.maxAttemptsReached');
+ const isVerifying = !maxAttemptsReached && state === BankAccount.STATE.VERIFYING;
+
return (
Navigation.goBack()}
+ shouldShowBackButton
/>
{maxAttemptsReached && (
@@ -225,21 +229,22 @@ class ValidationStep extends React.Component {
)}
- {!maxAttemptsReached && state === BankAccount.STATE.VERIFYING && (
+ {isVerifying && (
-
- {this.props.translate('validationStep.reviewingInfo')}
-
- {this.props.translate('common.here')}
-
- {this.props.translate('validationStep.forNextSteps')}
-
- Navigation.dismissModal()}
- />
+
+
+ {this.props.translate('validationStep.letsChatText')}
+
+
)}
diff --git a/src/pages/ReimbursementAccount/reimbursementAccountPropTypes.js b/src/pages/ReimbursementAccount/reimbursementAccountPropTypes.js
index e8551bcb062..a620be34127 100644
--- a/src/pages/ReimbursementAccount/reimbursementAccountPropTypes.js
+++ b/src/pages/ReimbursementAccount/reimbursementAccountPropTypes.js
@@ -31,5 +31,8 @@ export default PropTypes.shape({
error: PropTypes.string,
/** Object containing various errors */
- errors: PropTypes.objectOf(PropTypes.bool),
+ errors: PropTypes.objectOf(PropTypes.oneOfType([
+ PropTypes.bool,
+ PropTypes.arrayOf(PropTypes.objectOf(PropTypes.bool)),
+ ])),
});
diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js
index c8e54fd02de..371805e8029 100644
--- a/src/pages/ReportDetailsPage.js
+++ b/src/pages/ReportDetailsPage.js
@@ -92,7 +92,7 @@ class ReportDetailsPage extends Component {
this.menuItems = isArchivedRoom(this.props.report) ? []
: [
{
- translationKey: 'reportDetailsPage.members',
+ translationKey: 'common.members',
icon: Users,
subtitle: props.report.participants.length,
action: () => { Navigation.navigate(ROUTES.getReportParticipantsRoute(props.report.reportID)); },
diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js
index e922786f4c4..3c8651393a2 100755
--- a/src/pages/ReportParticipantsPage.js
+++ b/src/pages/ReportParticipantsPage.js
@@ -85,7 +85,7 @@ const ReportParticipantsPage = ({
return (
fetchOrCreateChatReport([
this.props.session.email,
@@ -178,19 +184,20 @@ class RequestCallPage extends Component {
], true)}
onCloseButtonPress={() => Navigation.dismissModal(true)}
/>
-
+
+
+ {this.props.translate('requestCallPage.subtitle')}
+
+
{this.props.translate('requestCallPage.description')}
-
- {this.props.translate('requestCallPage.instructions')}
-
this.setState({firstName})}
onChangeLastName={lastName => this.setState({lastName})}
- style={[styles.mt4, styles.mb4]}
+ style={[styles.mv4]}
/>
{
- if (_.isEmpty(this.state.phoneNumber.trim())) {
- this.setState({phoneNumberError: this.props.translate('messages.noPhoneNumber')});
- } else if (!Str.isValidPhone(this.state.phoneNumber)) {
- this.setState({phoneNumberError: this.props.translate('requestCallPage.errorMessageInvalidPhone')});
- } else {
- this.setState({phoneNumberError: ''});
- }
- }}
+ onBlur={this.validatePhoneInput}
onChangeText={phoneNumber => this.setState({phoneNumber})}
/>
-
- {this.props.translate('requestCallPage.availabilityText')}
-
-
+
-
{!_.isEmpty(error) && (
{error}
diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
index f2f9e2e89ae..ad8846eb600 100644
--- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
+++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
@@ -37,7 +37,9 @@ class PopoverReportActionContextMenu extends React.Component {
vertical: 0,
},
};
+ this.onPopoverShow = () => {};
this.onPopoverHide = () => {};
+ this.onPopoverHideActionCallback = () => {};
this.contextMenuAnchor = undefined;
this.showContextMenu = this.showContextMenu.bind(this);
this.hideContextMenu = this.hideContextMenu.bind(this);
@@ -46,6 +48,7 @@ class PopoverReportActionContextMenu extends React.Component {
this.confirmDeleteAndHideModal = this.confirmDeleteAndHideModal.bind(this);
this.hideDeleteModal = this.hideDeleteModal.bind(this);
this.showDeleteModal = this.showDeleteModal.bind(this);
+ this.runAndResetOnPopoverShow = this.runAndResetOnPopoverShow.bind(this);
this.runAndResetOnPopoverHide = this.runAndResetOnPopoverHide.bind(this);
this.getContextMenuMeasuredLocation = this.getContextMenuMeasuredLocation.bind(this);
this.isActiveReportAction = this.isActiveReportAction.bind(this);
@@ -117,7 +120,16 @@ class PopoverReportActionContextMenu extends React.Component {
) {
const nativeEvent = event.nativeEvent || {};
this.contextMenuAnchor = contextMenuAnchor;
- this.onPopoverHide = onHide;
+
+ // Singleton behaviour of ContextMenu creates race conditions when user requests multiple contextMenus.
+ // But it is possible that every new request registers new callbacks thus instanceID is used to corelate those callbacks
+ this.instanceID = Math.random().toString(36).substr(2, 5);
+
+ // Register the onHide callback only when Popover is shown to remove the race conditions when there are mutltiple popover open requests
+ this.onPopoverShow = () => {
+ onShow();
+ this.onPopoverHide = onHide;
+ };
this.getContextMenuMeasuredLocation().then(({x, y}) => {
this.setState({
cursorRelativePosition: {
@@ -134,7 +146,7 @@ class PopoverReportActionContextMenu extends React.Component {
selection,
isPopoverVisible: true,
reportActionDraftMessage: draftMessage,
- }, onShow);
+ });
});
}
@@ -159,22 +171,34 @@ class PopoverReportActionContextMenu extends React.Component {
}
/**
- * After Popover hides, call the registered onPopoverHide callback and reset it
+ * After Popover shows, call the registered onPopoverShow callback and reset it
+ */
+ runAndResetOnPopoverShow() {
+ this.onPopoverShow();
+
+ // After we have called the action, reset it.
+ this.onPopoverShow = () => {};
+ }
+
+ /**
+ * After Popover hides, call the registered onPopoverHide & onPopoverHideActionCallback callback and reset it
*/
runAndResetOnPopoverHide() {
this.onPopoverHide();
+ this.onPopoverHideActionCallback();
// After we have called the action, reset it.
this.onPopoverHide = () => {};
+ this.onPopoverHideActionCallback = () => {};
}
/**
* Hide the ReportActionContextMenu modal popover.
- * @param {Function} onHideCallback Callback to be called after popover is completely hidden
+ * @param {Function} onHideActionCallback Callback to be called after popover is completely hidden
*/
- hideContextMenu(onHideCallback) {
- if (_.isFunction(onHideCallback)) {
- this.onPopoverHide = onHideCallback;
+ hideContextMenu(onHideActionCallback) {
+ if (_.isFunction(onHideActionCallback)) {
+ this.onPopoverHideActionCallback = onHideActionCallback;
}
this.setState({
reportID: 0,
@@ -230,6 +254,7 @@ class PopoverReportActionContextMenu extends React.Component {
{}) {
return;
}
- setTimeout(() => contextMenuRef.current.hideContextMenu(onHideCallback), 800);
+
+ // Save the active instanceID for which hide action was called.
+ // If menu is being closed with a delay, check that whether the same instance exists or a new was created.
+ // If instance is not same, cancel the hide action
+ const instanceID = contextMenuRef.current.instanceID;
+ setTimeout(() => {
+ if (contextMenuRef.current.instanceID === instanceID) {
+ contextMenuRef.current.hideContextMenu(onHideCallback);
+ }
+ }, 800);
}
function hideDeleteModal() {
diff --git a/src/pages/home/report/EmojiPickerMenuItem.js b/src/pages/home/report/EmojiPickerMenuItem.js
index e464fa5eacf..1e24aac47ea 100644
--- a/src/pages/home/report/EmojiPickerMenuItem.js
+++ b/src/pages/home/report/EmojiPickerMenuItem.js
@@ -18,11 +18,6 @@ const propTypes = {
/** Whether this menu item is currently highlighted or not */
isHighlighted: PropTypes.bool,
-
- /** Override default emojiItem style */
- // eslint-disable-next-line react/forbid-prop-types
- emojiItemStyle: PropTypes.any,
-
};
const EmojiPickerMenuItem = props => (
@@ -34,7 +29,7 @@ const EmojiPickerMenuItem = props => (
styles.pv1,
getButtonBackgroundColorStyle(getButtonState(false, pressed)),
props.isHighlighted ? styles.emojiItemHighlighted : {},
- props.emojiItemStyle ? props.emojiItemStyle : styles.emojiItem,
+ styles.emojiItem,
])}
>
@@ -50,7 +45,6 @@ EmojiPickerMenuItem.displayName = 'EmojiPickerMenuItem';
EmojiPickerMenuItem.defaultProps = {
isHighlighted: false,
onHover: () => {},
- emojiItemStyle: undefined,
};
// Significantly speeds up re-renders of the EmojiPickerMenu's FlatList
diff --git a/src/pages/home/report/EmojiSkinToneList.js b/src/pages/home/report/EmojiSkinToneList.js
index e88cdc7f365..473cf67298d 100644
--- a/src/pages/home/report/EmojiSkinToneList.js
+++ b/src/pages/home/report/EmojiSkinToneList.js
@@ -2,7 +2,6 @@ import React, {Component} from 'react';
import {View, Pressable} from 'react-native';
import PropTypes from 'prop-types';
import styles from '../../../styles/styles';
-import compose from '../../../libs/compose';
import {skinTones} from '../../../../assets/emojis';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
import Text from '../../../components/Text';
@@ -45,14 +44,14 @@ class EmojiSkinToneList extends Component {
* @param {object} skinToneEmoji
*/
updateSelectedSkinTone(skinToneEmoji) {
- this.setState(prev => ({isSkinToneListVisible: !prev.isSkinToneListVisible}));
+ this.setState(prev => ({isSkinToneListVisible: !prev.isSkinToneListVisible, highlightedIndex: skinToneEmoji.skinTone}));
this.props.updatePreferredSkinTone(skinToneEmoji.skinTone);
}
render() {
const selectedEmoji = getSkinToneEmojiFromIndex(this.props.preferredSkinTone);
return (
-
+
{
!this.state.isSkinToneListVisible && (
-
+
{selectedEmoji.code}
@@ -79,21 +78,19 @@ class EmojiSkinToneList extends Component {
}
{
this.state.isSkinToneListVisible && (
-
-
- {
- skinTones.map(skinToneEmoji => (
- this.updateSelectedSkinTone(skinToneEmoji)}
- onHover={() => this.setState({highlightedIndex: skinToneEmoji.skinTone})}
- key={skinToneEmoji.code}
- emojiItemStyle={styles.emojiSkinToneItem}
- emoji={skinToneEmoji.code}
- isHighlighted={skinToneEmoji.skinTone === this.state.highlightedIndex}
- />
- ))
+
+
+ {
+ skinTones.map(skinToneEmoji => (
+ this.updateSelectedSkinTone(skinToneEmoji)}
+ onHover={() => this.setState({highlightedIndex: skinToneEmoji.skinTone})}
+ key={skinToneEmoji.code}
+ emoji={skinToneEmoji.code}
+ isHighlighted={skinToneEmoji.skinTone === this.state.highlightedIndex}
+ />
+ ))
}
-
)
}
@@ -104,6 +101,4 @@ class EmojiSkinToneList extends Component {
EmojiSkinToneList.propTypes = propTypes;
-export default compose(
- withLocalize,
-)(EmojiSkinToneList);
+export default withLocalize(EmojiSkinToneList);
diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js
index 62557a51df5..1948c45b78c 100755
--- a/src/pages/home/report/ReportActionCompose.js
+++ b/src/pages/home/report/ReportActionCompose.js
@@ -560,6 +560,19 @@ class ReportActionCompose extends React.Component {
},
},
] : []),
+ ...(Permissions.canUseIOUSend(this.props.betas) && !hasMultipleParticipants ? [
+ {
+ icon: Send,
+ text: this.props.translate('iou.sendMoney'),
+ onSelected: () => {
+ Navigation.navigate(
+ ROUTES.getIOUSendRoute(
+ this.props.reportID,
+ ),
+ );
+ },
+ },
+ ] : []),
{
icon: Paperclip,
text: this.props.translate('reportActionCompose.addAttachment'),
diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js
index cd9e563eaa3..a1cbda2fdc1 100644
--- a/src/pages/home/report/ReportActionItemFragment.js
+++ b/src/pages/home/report/ReportActionItemFragment.js
@@ -66,7 +66,7 @@ class ReportActionItemFragment extends React.PureComponent {
return fragment.html !== fragment.text
? (
' : '')}
+ html={`${fragment.html + (fragment.isEdited ? '' : '')}`}
/>
) : (
- showUserDetails(action.actorEmail)}>
+ showUserDetails(action.actorEmail)}>
({
- localUnreadActionCount: shouldResetLocalCount
+ this.setState((prevState) => {
+ const localUnreadActionCount = shouldResetLocalCount
? this.props.report.unreadActionCount
- : prevState.localUnreadActionCount + this.props.report.unreadActionCount,
- }));
+ : prevState.localUnreadActionCount + this.props.report.unreadActionCount;
+ this.updateUnreadIndicatorPosition(localUnreadActionCount);
+ return {localUnreadActionCount};
+ });
}
/**
diff --git a/src/pages/home/sidebar/SidebarScreen.js b/src/pages/home/sidebar/SidebarScreen.js
index 437cec2766c..8eef76a9178 100755
--- a/src/pages/home/sidebar/SidebarScreen.js
+++ b/src/pages/home/sidebar/SidebarScreen.js
@@ -1,7 +1,10 @@
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
import React, {Component} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
+import {withNavigation} from '@react-navigation/compat';
import styles from '../../../styles/styles';
import SidebarLinks from './SidebarLinks';
import PopoverMenu from '../../../components/PopoverMenu';
@@ -20,6 +23,7 @@ import {
MoneyCircle,
Receipt,
NewWorkspace,
+ Send,
} from '../../../components/Icon/Expensicons';
import Permissions from '../../../libs/Permissions';
import ONYXKEYS from '../../../ONYXKEYS';
@@ -32,12 +36,15 @@ const propTypes = {
betas: PropTypes.arrayOf(PropTypes.string).isRequired,
/* Flag for new users used to open the Global Create menu on first load */
- isFirstTimeNewExpensifyUser: PropTypes.bool.isRequired,
+ isFirstTimeNewExpensifyUser: PropTypes.bool,
...windowDimensionsPropTypes,
...withLocalizePropTypes,
};
+const defaultProps = {
+ isFirstTimeNewExpensifyUser: false,
+};
class SidebarScreen extends Component {
constructor(props) {
@@ -61,7 +68,17 @@ class SidebarScreen extends Component {
// This is a short-term workaround, see this issue for updates on a long-term solution: https://github.com/Expensify/App/issues/5296
setTimeout(() => {
if (this.props.isFirstTimeNewExpensifyUser) {
- this.toggleCreateMenu();
+ // If we are rendering the SidebarScreen at the same time as a workspace route that means we've already created a workspace via workspace/new and should not open the global
+ // create menu right now.
+ const routes = lodashGet(this.props.navigation.getState(), 'routes', []);
+ const topRouteName = lodashGet(_.last(routes), 'name', '');
+ const isDisplayingWorkspaceRoute = topRouteName.toLowerCase().includes('workspace');
+
+ // It's also possible that we already have a workspace policy. In either case we will not toggle the menu but do still want to set the NVP in this case since the user does
+ // not need to create a workspace.
+ if (!isAdminOfFreePolicy(this.props.allPolicies) && !isDisplayingWorkspaceRoute) {
+ this.toggleCreateMenu();
+ }
// Set the NVP back to false so we don't automatically open the menu again
// Note: this may need to be moved if this NVP is used for anything else later
@@ -145,6 +162,13 @@ class SidebarScreen extends Component {
text: this.props.translate('sidebarScreen.newGroup'),
onSelected: () => Navigation.navigate(ROUTES.NEW_GROUP),
},
+ ...(Permissions.canUseIOUSend(this.props.betas) ? [
+ {
+ icon: Send,
+ text: this.props.translate('iou.sendMoney'),
+ onSelected: () => Navigation.navigate(ROUTES.IOU_SEND),
+ },
+ ] : []),
...(Permissions.canUseIOU(this.props.betas) ? [
{
icon: MoneyCircle,
@@ -179,7 +203,10 @@ class SidebarScreen extends Component {
}
SidebarScreen.propTypes = propTypes;
+SidebarScreen.defaultProps = defaultProps;
+
export default compose(
+ withNavigation,
withLocalize,
withWindowDimensions,
withOnyx({
diff --git a/src/pages/iou/IOUDetailsModal.js b/src/pages/iou/IOUDetailsModal.js
index 9022d29bbae..7adb86acd76 100644
--- a/src/pages/iou/IOUDetailsModal.js
+++ b/src/pages/iou/IOUDetailsModal.js
@@ -25,6 +25,7 @@ import Permissions from '../../libs/Permissions';
import {
Cash, PayPal, Venmo, Wallet,
} from '../../components/Icon/Expensicons';
+import {isValidUSPhone} from '../../libs/ValidationUtils';
const propTypes = {
/** URL Route params */
@@ -132,18 +133,6 @@ class IOUDetailsModal extends Component {
});
}
- /**
- * @param {String} phoneNumber
- * @returns {Boolean}
- */
- isValidUSPhone(phoneNumber) {
- // Remove alphanumeric characters and validate that this is in fact a phone number
- return CONST.REGEX.PHONE_E164_PLUS.test(phoneNumber.replace(CONST.REGEX.NON_ALPHA_NUMERIC, ''))
-
- // Next make sure it's a US phone number
- && CONST.REGEX.US_PHONE.test(phoneNumber);
- }
-
/**
* Checks to see if we can use Venmo. The following conditions must be met:
*
@@ -162,7 +151,7 @@ class IOUDetailsModal extends Component {
return;
}
- this.submitterPhoneNumber = _.find(submitterPhoneNumbers, this.isValidUSPhone);
+ this.submitterPhoneNumber = _.find(submitterPhoneNumbers, isValidUSPhone);
if (!this.submitterPhoneNumber) {
return;
}
diff --git a/src/pages/iou/IOUModal.js b/src/pages/iou/IOUModal.js
index f71ce27f4d8..f040e6bd4fd 100755
--- a/src/pages/iou/IOUModal.js
+++ b/src/pages/iou/IOUModal.js
@@ -87,7 +87,7 @@ const defaultProps = {
myPersonalDetails: {
localCurrencyCode: CONST.CURRENCY.USD,
},
- iouType: '',
+ iouType: CONST.IOU.IOU_TYPE.REQUEST,
};
// Determines type of step to display within Modal, value provides the title for that page.
@@ -113,6 +113,8 @@ class IOUModal extends Component {
alternateText: Str.isSMSLogin(personalDetails.login) ? Str.removeSMSDomain(personalDetails.login) : personalDetails.login,
icons: [personalDetails.avatar],
keyForList: personalDetails.login,
+ payPalMeAddress: personalDetails.payPalMeAddress ?? '',
+ phoneNumber: personalDetails.phoneNumber ?? '',
}));
this.state = {
@@ -171,7 +173,7 @@ class IOUModal extends Component {
currency: this.props.iou.selectedCurrencyCode,
},
);
- if (this.props.iouType === 'send') {
+ if (this.props.iouType === CONST.IOU.IOU_TYPE.SEND) {
return this.props.translate('iou.send', {
amount: formattedAmount,
});
@@ -183,7 +185,7 @@ class IOUModal extends Component {
);
}
if (currentStepIndex === 0) {
- if (this.props.iouType === 'send') {
+ if (this.props.iouType === CONST.IOU.IOU_TYPE.SEND) {
return this.props.translate('iou.sendMoney');
}
return this.props.translate(this.props.hasMultipleParticipants ? 'iou.splitBill' : 'iou.requestMoney');
@@ -352,6 +354,8 @@ class IOUModal extends Component {
iouAmount={this.state.amount}
comment={this.state.comment}
onUpdateComment={this.updateComment}
+ iouType={this.props.iouType}
+ localCurrencyCode={this.props.myPersonalDetails.localCurrencyCode}
/>
)}
>
diff --git a/src/pages/iou/IOUSendPage.js b/src/pages/iou/IOUSendPage.js
index 62f6e4ee25b..13975708ae1 100644
--- a/src/pages/iou/IOUSendPage.js
+++ b/src/pages/iou/IOUSendPage.js
@@ -1,5 +1,6 @@
import React from 'react';
+import CONST from '../../CONST';
import IOUModal from './IOUModal';
// eslint-disable-next-line react/jsx-props-no-spreading
-export default props => ;
+export default props => ;
diff --git a/src/pages/iou/steps/IOUAmountPage.js b/src/pages/iou/steps/IOUAmountPage.js
index da49f58e028..c03824f18bc 100755
--- a/src/pages/iou/steps/IOUAmountPage.js
+++ b/src/pages/iou/steps/IOUAmountPage.js
@@ -101,7 +101,7 @@ class IOUAmountPage extends React.Component {
*/
validateAmount(amount) {
const decimalNumberRegex = new RegExp(/^\d+(,\d+)*(\.\d{0,3})?$/, 'i');
- return amount === '' || (decimalNumberRegex.test(amount) && ((parseFloat(amount) * 100).toString().length <= CONST.IOU.AMOUNT_MAX_LENGTH));
+ return amount === '' || (decimalNumberRegex.test(amount) && (parseFloat((amount * 100).toFixed(3)).toString().length <= CONST.IOU.AMOUNT_MAX_LENGTH));
}
/**
diff --git a/src/pages/iou/steps/IOUConfirmPage.js b/src/pages/iou/steps/IOUConfirmPage.js
index 4e1f97142fe..8ddcb864fff 100644
--- a/src/pages/iou/steps/IOUConfirmPage.js
+++ b/src/pages/iou/steps/IOUConfirmPage.js
@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import IOUConfirmationList from '../../../components/IOUConfirmationList';
+import CONST from '../../../CONST';
const propTypes = {
/** Callback to inform parent modal of success */
@@ -18,6 +19,8 @@ const propTypes = {
/** IOU amount */
iouAmount: PropTypes.string.isRequired,
+ localCurrencyCode: PropTypes.string,
+
/** Selected participants from IOUMOdal with login */
participants: PropTypes.arrayOf(PropTypes.shape({
login: PropTypes.string.isRequired,
@@ -31,13 +34,19 @@ const propTypes = {
isUnread: PropTypes.bool,
reportID: PropTypes.number,
participantsList: PropTypes.arrayOf(PropTypes.object),
+ payPalMeAddress: PropTypes.string,
+ phoneNumber: PropTypes.string,
})).isRequired,
+ /** IOU type */
+ iouType: PropTypes.string,
};
const defaultProps = {
onUpdateComment: null,
comment: '',
+ iouType: CONST.IOU.IOU_TYPE.REQUEST,
+ localCurrencyCode: CONST.CURRENCY.USD,
};
const IOUConfirmPage = props => (
@@ -48,6 +57,8 @@ const IOUConfirmPage = props => (
onUpdateComment={props.onUpdateComment}
iouAmount={props.iouAmount}
onConfirm={props.onConfirm}
+ iouType={props.iouType}
+ localCurrencyCode={props.localCurrencyCode}
/>
);
diff --git a/src/pages/settings/AboutPage.js b/src/pages/settings/AboutPage.js
index 22588ba8162..14c3a1950a1 100644
--- a/src/pages/settings/AboutPage.js
+++ b/src/pages/settings/AboutPage.js
@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
-import {View, ScrollView, Linking} from 'react-native';
+import {View, ScrollView} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
import Navigation from '../../libs/Navigation/Navigation';
@@ -23,6 +23,7 @@ import Logo from '../../../assets/images/new-expensify.svg';
import {version} from '../../../package.json';
import {fetchOrCreateChatReport} from '../../libs/actions/Report';
import ONYXKEYS from '../../ONYXKEYS';
+import {openExternalLink} from '../../libs/actions/Link';
const propTypes = {
/** Onyx Props */
@@ -50,7 +51,7 @@ const AboutPage = ({translate, session}) => {
icon: Eye,
iconRight: NewWindow,
action: () => {
- Linking.openURL(CONST.GITHUB_URL);
+ openExternalLink(CONST.GITHUB_URL);
},
},
{
@@ -58,7 +59,7 @@ const AboutPage = ({translate, session}) => {
icon: MoneyBag,
iconRight: NewWindow,
action: () => {
- Linking.openURL(CONST.UPWORK_URL);
+ openExternalLink(CONST.UPWORK_URL);
},
},
{
@@ -128,7 +129,7 @@ const AboutPage = ({translate, session}) => {
{' '}
Linking.openURL(CONST.TERMS_URL)}
+ onPress={() => openExternalLink(CONST.TERMS_URL)}
>
{translate(
'initialSettingsPage.readTheTermsAndPrivacyPolicy.phrase2',
@@ -141,7 +142,7 @@ const AboutPage = ({translate, session}) => {
{' '}
Linking.openURL(CONST.PRIVACY_URL)}
+ onPress={() => openExternalLink(CONST.PRIVACY_URL)}
>
{translate(
'initialSettingsPage.readTheTermsAndPrivacyPolicy.phrase4',
diff --git a/src/pages/settings/AddSecondaryLoginPage.js b/src/pages/settings/AddSecondaryLoginPage.js
index 8438f3caded..770381bcc9e 100755
--- a/src/pages/settings/AddSecondaryLoginPage.js
+++ b/src/pages/settings/AddSecondaryLoginPage.js
@@ -19,31 +19,11 @@ import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize
import compose from '../../libs/compose';
import FixedFooter from '../../components/FixedFooter';
import ExpensiTextInput from '../../components/ExpensiTextInput';
+import userPropTypes from './userPropTypes';
const propTypes = {
/* Onyx Props */
-
- /** The details about the user that is signed in */
- user: PropTypes.shape({
- /** error associated with adding a secondary login */
- error: PropTypes.string,
-
- /** Whether the form is being submitted */
- loading: PropTypes.bool,
-
- /** Whether or not the user is subscribed to news updates */
- loginList: PropTypes.arrayOf(PropTypes.shape({
-
- /** Value of partner name */
- partnerName: PropTypes.string,
-
- /** Phone/Email associated with user */
- partnerUserID: PropTypes.string,
-
- /** Date of when login was validated */
- validatedDate: PropTypes.string,
- })),
- }),
+ user: userPropTypes,
// Route object from navigation
route: PropTypes.shape({
diff --git a/src/pages/settings/AppDownloadLinks.js b/src/pages/settings/AppDownloadLinks.js
index 03d474aacda..014ad1b6ba5 100644
--- a/src/pages/settings/AppDownloadLinks.js
+++ b/src/pages/settings/AppDownloadLinks.js
@@ -1,5 +1,5 @@
import React from 'react';
-import {ScrollView, Linking} from 'react-native';
+import {ScrollView} from 'react-native';
import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
import Navigation from '../../libs/Navigation/Navigation';
import CONST from '../../CONST';
@@ -8,9 +8,9 @@ import {
} from '../../components/Icon/Expensicons';
import ScreenWrapper from '../../components/ScreenWrapper';
import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
-import compose from '../../libs/compose';
import MenuItem from '../../components/MenuItem';
import styles from '../../styles/styles';
+import {openExternalLink} from '../../libs/actions/Link';
const propTypes = {
...withLocalizePropTypes,
@@ -22,19 +22,19 @@ const AppDownloadLinksPage = ({translate}) => {
translationKey: 'initialSettingsPage.appDownloadLinks.android.label',
icon: Android,
iconRight: NewWindow,
- action: () => { Linking.openURL(CONST.APP_DOWNLOAD_LINKS.ANDROID); },
+ action: () => { openExternalLink(CONST.APP_DOWNLOAD_LINKS.ANDROID); },
},
{
translationKey: 'initialSettingsPage.appDownloadLinks.ios.label',
icon: Apple,
iconRight: NewWindow,
- action: () => { Linking.openURL(CONST.APP_DOWNLOAD_LINKS.IOS); },
+ action: () => { openExternalLink(CONST.APP_DOWNLOAD_LINKS.IOS); },
},
{
translationKey: 'initialSettingsPage.appDownloadLinks.desktop.label',
icon: Monitor,
iconRight: NewWindow,
- action: () => { Linking.openURL(CONST.APP_DOWNLOAD_LINKS.DESKTOP); },
+ action: () => { openExternalLink(CONST.APP_DOWNLOAD_LINKS.DESKTOP); },
},
];
@@ -65,6 +65,4 @@ const AppDownloadLinksPage = ({translate}) => {
AppDownloadLinksPage.propTypes = propTypes;
AppDownloadLinksPage.displayName = 'AppDownloadLinksPage';
-export default compose(
- withLocalize,
-)(AppDownloadLinksPage);
+export default withLocalize(AppDownloadLinksPage);
diff --git a/src/pages/settings/InitialPage.js b/src/pages/settings/InitialPage.js
index 37ca51a0891..da23f72c3d2 100755
--- a/src/pages/settings/InitialPage.js
+++ b/src/pages/settings/InitialPage.js
@@ -157,7 +157,7 @@ const InitialSettingsPage = ({
title: policy.name,
icon: policy.avatarURL ? policy.avatarURL : Building,
iconType: policy.avatarURL ? CONST.ICON_TYPE_AVATAR : CONST.ICON_TYPE_ICON,
- action: () => Navigation.navigate(ROUTES.getWorkspaceCardRoute(policy.id)),
+ action: () => Navigation.navigate(ROUTES.getWorkspaceInitialRoute(policy.id)),
iconStyles: [styles.popoverMenuIconEmphasized],
iconFill: themeColors.iconReversed,
}))
diff --git a/src/pages/settings/Payments/AddDebitCardPage.js b/src/pages/settings/Payments/AddDebitCardPage.js
new file mode 100644
index 00000000000..57cfc5cef4d
--- /dev/null
+++ b/src/pages/settings/Payments/AddDebitCardPage.js
@@ -0,0 +1,248 @@
+import React, {Component} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import {
+ View,
+ ScrollView,
+} from 'react-native';
+import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton';
+import Navigation from '../../../libs/Navigation/Navigation';
+import ScreenWrapper from '../../../components/ScreenWrapper';
+import TextInputWithLabel from '../../../components/TextInputWithLabel';
+import styles from '../../../styles/styles';
+import StatePicker from '../../../components/StatePicker';
+import Text from '../../../components/Text';
+import TextLink from '../../../components/TextLink';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import compose from '../../../libs/compose';
+import {addBillingCard} from '../../../libs/actions/PaymentMethods';
+import Button from '../../../components/Button';
+import KeyboardAvoidingView from '../../../components/KeyboardAvoidingView';
+import FixedFooter from '../../../components/FixedFooter';
+import Growl from '../../../libs/Growl';
+import {
+ isValidAddress, isValidExpirationDate, isValidZipCode, isValidDebitCard, isValidSecurityCode,
+} from '../../../libs/ValidationUtils';
+import CheckboxWithLabel from '../../../components/CheckboxWithLabel';
+
+const propTypes = {
+ /* Onyx Props */
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+};
+
+class DebitCardPage extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ nameOnCard: '',
+ cardNumber: '',
+ expirationDate: '',
+ securityCode: '',
+ billingAddress: '',
+ city: '',
+ selectedState: '',
+ zipCode: '',
+ acceptedTerms: false,
+ isAddingCard: false,
+ };
+
+ this.toggleTermsOfService = this.toggleTermsOfService.bind(this);
+ this.handleExpirationInput = this.handleExpirationInput.bind(this);
+ this.handleCardNumberInput = this.handleCardNumberInput.bind(this);
+ this.submit = this.submit.bind(this);
+ }
+
+ /**
+ * @returns {Boolean}
+ */
+ validate() {
+ if (this.state.nameOnCard === '') {
+ Growl.error(this.props.translate('addDebitCardPage.error.invalidName'));
+ return false;
+ }
+
+ if (!isValidDebitCard(this.state.cardNumber.replace(/ /g, ''))) {
+ Growl.error(this.props.translate('addDebitCardPage.error.debitCardNumber'));
+ return false;
+ }
+
+ if (!isValidExpirationDate(this.state.expirationDate)) {
+ Growl.error(this.props.translate('addDebitCardPage.error.expirationDate'));
+ return false;
+ }
+
+ if (!isValidSecurityCode(this.state.securityCode)) {
+ Growl.error(this.props.translate('addDebitCardPage.error.securityCode'));
+ return false;
+ }
+
+ if (!isValidAddress(this.state.billingAddress)) {
+ Growl.error(this.props.translate('addDebitCardPage.error.address'));
+ return false;
+ }
+
+ if (this.state.city === '') {
+ Growl.error(this.props.translate('addDebitCardPage.error.addressCity'));
+ return false;
+ }
+
+ if (this.state.selectedState === '') {
+ Growl.error(this.props.translate('addDebitCardPage.error.addressState'));
+ return false;
+ }
+
+ if (!isValidZipCode(this.state.zipCode)) {
+ Growl.error(this.props.translate('addDebitCardPage.error.zipCode'));
+ return false;
+ }
+
+ if (!this.state.acceptedTerms) {
+ Growl.error(this.props.translate('addDebitCardPage.error.acceptedTerms'));
+ return false;
+ }
+
+ return true;
+ }
+
+ submit() {
+ if (!this.validate()) {
+ return;
+ }
+ this.setState({isAddingCard: true});
+ addBillingCard(this.state);
+ }
+
+ toggleTermsOfService() {
+ this.setState(prevState => ({acceptedTerms: !prevState.acceptedTerms}));
+ }
+
+ handleExpirationInput(expirationDate) {
+ let newExpirationDate = expirationDate;
+ const isErasing = expirationDate.length < this.state.expirationDate.length;
+ if (expirationDate.length === 2 && !isErasing) {
+ newExpirationDate = `${expirationDate}/`;
+ }
+ this.setState({expirationDate: newExpirationDate});
+ }
+
+ handleCardNumberInput(newCardNumber) {
+ if (/^[0-9]{0,16}$/.test(newCardNumber)) {
+ this.setState({cardNumber: newCardNumber});
+ }
+ }
+
+ render() {
+ return (
+
+
+ Navigation.dismissModal(true)}
+ />
+
+ this.setState({nameOnCard})}
+ value={this.state.nameOnCard}
+ />
+ this.handleCardNumberInput(cardNumber)}
+ value={this.state.cardNumber}
+ />
+
+ this.handleExpirationInput(expirationDate)}
+ value={this.state.expirationDate}
+ />
+ this.setState({securityCode})}
+ value={this.state.securityCode}
+ />
+
+ this.setState({billingAddress})}
+ value={this.state.billingAddress}
+ />
+ this.setState({city})}
+ value={this.state.city}
+ />
+
+
+
+ {this.props.translate('common.state')}
+
+ this.setState({selectedState: state})}
+ value={this.state.selectedState}
+ />
+
+ this.setState({zipCode})}
+ value={this.state.zipCode}
+ />
+
+ (
+
+ {`${this.props.translate('common.iAcceptThe')} `}
+
+ {`${this.props.translate('addDebitCardPage.expensifyTermsOfService')}`}
+
+
+ )}
+ />
+
+
+
+
+
+
+ );
+ }
+}
+
+DebitCardPage.propTypes = propTypes;
+DebitCardPage.defaultProps = defaultProps;
+DebitCardPage.displayName = 'DebitCardPage';
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ }),
+)(DebitCardPage);
diff --git a/src/pages/settings/Payments/AddPayPalMePage.js b/src/pages/settings/Payments/AddPayPalMePage.js
index 65cd22071df..58905d2d6e8 100644
--- a/src/pages/settings/Payments/AddPayPalMePage.js
+++ b/src/pages/settings/Payments/AddPayPalMePage.js
@@ -9,7 +9,7 @@ import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton';
import Text from '../../../components/Text';
import ScreenWrapper from '../../../components/ScreenWrapper';
import NameValuePair from '../../../libs/actions/NameValuePair';
-import getPaymentMethods from '../../../libs/actions/PaymentMethods';
+import {getPaymentMethods} from '../../../libs/actions/PaymentMethods';
import Navigation from '../../../libs/Navigation/Navigation';
import styles from '../../../styles/styles';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
diff --git a/src/pages/settings/Payments/PaymentMethodList.js b/src/pages/settings/Payments/PaymentMethodList.js
index 26038c8dfbc..5187b0b6bec 100644
--- a/src/pages/settings/Payments/PaymentMethodList.js
+++ b/src/pages/settings/Payments/PaymentMethodList.js
@@ -3,6 +3,7 @@ import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {FlatList, Text} from 'react-native';
import {withOnyx} from 'react-native-onyx';
+import lodashGet from 'lodash/get';
import styles from '../../../styles/styles';
import MenuItem from '../../../components/MenuItem';
import compose from '../../../libs/compose';
@@ -14,6 +15,7 @@ import {
Plus,
} from '../../../components/Icon/Expensicons';
import getBankIcon from '../../../components/Icon/BankIcons';
+import bankAccountPropTypes from '../../../components/bankAccountPropTypes';
const MENU_ITEM = 'menuItem';
@@ -28,19 +30,7 @@ const propTypes = {
payPalMeUsername: PropTypes.string,
/** Array of bank account objects */
- bankAccountList: PropTypes.arrayOf(PropTypes.shape({
- /** The name of the institution (bank of america, etc */
- addressName: PropTypes.string,
-
- /** The masked bank account number */
- accountNumber: PropTypes.string,
-
- /** The bankAccountID in the bankAccounts db */
- bankAccountID: PropTypes.number,
-
- /** The bank account type */
- type: PropTypes.string,
- })),
+ bankAccountList: PropTypes.arrayOf(bankAccountPropTypes),
/** Array of card objects */
cardList: PropTypes.arrayOf(PropTypes.shape({
@@ -87,7 +77,7 @@ class PaymentMethodList extends Component {
bankAccount.accountNumber.slice(-4)
}`
: null;
- const {icon, iconSize} = getBankIcon(bankAccount.additionalData.bankName);
+ const {icon, iconSize} = getBankIcon(lodashGet(bankAccount, 'additionalData.bankName', ''));
combinedPaymentMethods.push({
type: MENU_ITEM,
title: bankAccount.addressName,
@@ -114,8 +104,8 @@ class PaymentMethodList extends Component {
description: formattedCardNumber,
icon,
iconSize,
- onPress: e => this.props.onPress(e, card.cardID),
- key: `card-${card.cardID}`,
+ onPress: e => this.props.onPress(e, card.cardNumber),
+ key: `card-${card.cardNumber}`,
});
});
diff --git a/src/pages/settings/Payments/PaymentsPage.js b/src/pages/settings/Payments/PaymentsPage.js
index 92202ca6f6a..b4014f6d8a9 100644
--- a/src/pages/settings/Payments/PaymentsPage.js
+++ b/src/pages/settings/Payments/PaymentsPage.js
@@ -12,9 +12,9 @@ import withLocalize, {withLocalizePropTypes} from '../../../components/withLocal
import compose from '../../../libs/compose';
import KeyboardAvoidingView from '../../../components/KeyboardAvoidingView/index';
import Text from '../../../components/Text';
-import getPaymentMethods from '../../../libs/actions/PaymentMethods';
+import {getPaymentMethods} from '../../../libs/actions/PaymentMethods';
import Popover from '../../../components/Popover';
-import {PayPal} from '../../../components/Icon/Expensicons';
+import {PayPal, CreditCard} from '../../../components/Icon/Expensicons';
import MenuItem from '../../../components/MenuItem';
import getClickedElementLocation from '../../../libs/getClickedElementLocation';
import CurrentWalletBalance from '../../../components/CurrentWalletBalance';
@@ -22,16 +22,21 @@ import ONYXKEYS from '../../../ONYXKEYS';
import Permissions from '../../../libs/Permissions';
const PAYPAL = 'payPalMe';
+const DEBIT_CARD = 'debitCard';
const propTypes = {
...withLocalizePropTypes,
/** List of betas available to current user */
betas: PropTypes.arrayOf(PropTypes.string),
+
+ /** Are we loading payment methods? */
+ isLoadingPaymentMethods: PropTypes.bool,
};
const defaultProps = {
betas: [],
+ isLoadingPaymentMethods: true,
};
class PaymentsPage extends React.Component {
@@ -42,7 +47,6 @@ class PaymentsPage extends React.Component {
shouldShowAddPaymentMenu: false,
anchorPositionTop: 0,
anchorPositionLeft: 0,
- isLoadingPaymentMethods: true,
};
this.paymentMethodPressed = this.paymentMethodPressed.bind(this);
@@ -51,9 +55,7 @@ class PaymentsPage extends React.Component {
}
componentDidMount() {
- getPaymentMethods().then(() => {
- this.setState({isLoadingPaymentMethods: false});
- });
+ getPaymentMethods();
}
/**
@@ -90,6 +92,10 @@ class PaymentsPage extends React.Component {
if (paymentType === PAYPAL) {
Navigation.navigate(ROUTES.SETTINGS_ADD_PAYPAL_ME);
}
+
+ if (paymentType === DEBIT_CARD) {
+ Navigation.navigate(ROUTES.SETTINGS_ADD_DEBIT_CARD);
+ }
}
/**
@@ -121,7 +127,7 @@ class PaymentsPage extends React.Component {
this.addPaymentMethodTypePressed(PAYPAL)}
/>
+
@@ -146,7 +157,6 @@ class PaymentsPage extends React.Component {
PaymentsPage.propTypes = propTypes;
PaymentsPage.defaultProps = defaultProps;
-PaymentsPage.displayName = 'PaymentsPage';
export default compose(
withLocalize,
@@ -154,5 +164,9 @@ export default compose(
betas: {
key: ONYXKEYS.BETAS,
},
+ isLoadingPaymentMethods: {
+ key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
+ initWithStoredValues: false,
+ },
}),
)(PaymentsPage);
diff --git a/src/pages/settings/userPropTypes.js b/src/pages/settings/userPropTypes.js
new file mode 100644
index 00000000000..9d6deee1653
--- /dev/null
+++ b/src/pages/settings/userPropTypes.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+
+export default PropTypes.shape({
+ /** error associated with adding a secondary login */
+ error: PropTypes.string,
+
+ /** Whether or not the user is on a public domain email account or not */
+ isFromPublicDomain: PropTypes.bool,
+
+ /** Whether the form is being submitted */
+ loading: PropTypes.bool,
+
+ /** The list of logins that exist for this users account */
+ loginList: PropTypes.arrayOf(PropTypes.shape({
+
+ /** Value of partner name */
+ partnerName: PropTypes.string,
+
+ /** Phone/Email associated with user */
+ partnerUserID: PropTypes.string,
+
+ /** Date of when login was validated */
+ validatedDate: PropTypes.string,
+ })),
+});
diff --git a/src/pages/signin/LoginForm.js b/src/pages/signin/LoginForm.js
index 4b2eb6e9321..434b41b878f 100755
--- a/src/pages/signin/LoginForm.js
+++ b/src/pages/signin/LoginForm.js
@@ -44,6 +44,7 @@ class LoginForm extends React.Component {
constructor(props) {
super(props);
+ this.onTextInput = this.onTextInput.bind(this);
this.validateAndSubmitForm = this.validateAndSubmitForm.bind(this);
this.state = {
@@ -52,12 +53,24 @@ class LoginForm extends React.Component {
};
}
+ /**
+ * Handle text input and clear formError upon text change
+ *
+ * @param {String} text
+ */
+ onTextInput(text) {
+ this.setState({
+ login: text,
+ formError: null,
+ });
+ }
+
/**
* Check that all the form fields are valid, then trigger the submit callback
*/
validateAndSubmitForm() {
if (!this.state.login.trim()) {
- this.setState({formError: 'loginForm.pleaseEnterEmailOrPhoneNumber'});
+ this.setState({formError: 'common.pleaseEnterEmailOrPhoneNumber'});
return;
}
@@ -83,7 +96,7 @@ class LoginForm extends React.Component {
value={this.state.login}
autoCompleteType="email"
textContentType="username"
- onChangeText={text => this.setState({login: text})}
+ onChangeText={this.onTextInput}
onSubmitEditing={this.validateAndSubmitForm}
autoCapitalize="none"
autoCorrect={false}
diff --git a/src/pages/signin/ResendValidationForm.js b/src/pages/signin/ResendValidationForm.js
index f494941421b..29411cd7696 100755
--- a/src/pages/signin/ResendValidationForm.js
+++ b/src/pages/signin/ResendValidationForm.js
@@ -1,5 +1,5 @@
import React from 'react';
-import {View} from 'react-native';
+import {TouchableOpacity, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
import _ from 'underscore';
@@ -9,9 +9,11 @@ import Button from '../../components/Button';
import Text from '../../components/Text';
import {reopenAccount, resendValidationLink, resetPassword} from '../../libs/actions/Session';
import ONYXKEYS from '../../ONYXKEYS';
-import ChangeExpensifyLoginLink from './ChangeExpensifyLoginLink';
import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
import compose from '../../libs/compose';
+import redirectToSignIn from '../../libs/actions/SignInRedirect';
+import Avatar from '../../components/Avatar';
+import {getDefaultAvatar} from '../../libs/OptionsListUtils';
const propTypes = {
/* Onyx Props */
@@ -32,6 +34,9 @@ const propTypes = {
/** Whether or not the account is closed */
closed: PropTypes.bool,
+
+ /** Whether or not the account already exists */
+ accountExists: PropTypes.bool,
}),
...withLocalizePropTypes,
@@ -80,15 +85,40 @@ class ResendValidationForm extends React.Component {
}
render() {
+ const isNewAccount = !this.props.account.accountExists;
+ const isOldUnvalidatedAccount = this.props.account.accountExists && !this.props.account.validated;
+ const isSMSLogin = Str.isSMSLogin(this.props.credentials.login);
+ const login = isSMSLogin ? this.props.toLocalPhone(Str.removeSMSDomain(this.props.credentials.login)) : this.props.credentials.login;
+ const loginType = (isSMSLogin ? this.props.translate('common.phone') : this.props.translate('common.email')).toLowerCase();
+ let message = '';
+
+ if (isNewAccount) {
+ message = this.props.translate('resendValidationForm.newAccount', {
+ login,
+ loginType,
+ });
+ } else if (isOldUnvalidatedAccount) {
+ message = this.props.translate('resendValidationForm.unvalidatedAccount');
+ } else {
+ message = this.props.translate('resendValidationForm.weSentYouMagicSignInLink', {
+ login,
+ });
+ }
+
return (
<>
-
+
+
+
+ {login}
+
+
+
- {this.props.translate('resendValidationForm.weSentYouMagicSignInLink', {
- loginType: (Str.isSMSLogin(this.props.credentials.login)
- ? this.props.translate('common.phoneNumber').toLowerCase()
- : this.props.translate('common.email')).toLowerCase(),
- })}
+ {message}
{!_.isEmpty(this.state.formSuccess) && (
@@ -96,15 +126,18 @@ class ResendValidationForm extends React.Component {
{this.state.formSuccess}
)}
-
+
+ redirectToSignIn()}>
+
+ {this.props.translate('common.back')}
+
+
-
>
);
diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js
index ce47478da5d..05bc0feb6b4 100644
--- a/src/pages/signin/SignInPage.js
+++ b/src/pages/signin/SignInPage.js
@@ -92,7 +92,7 @@ class SignInPage extends Component {
{showLoginForm && }
diff --git a/src/pages/signin/SignInPageLayout/SignInPageLayoutNarrow.js b/src/pages/signin/SignInPageLayout/SignInPageLayoutNarrow.js
index 74d213eff0c..4ab52883f60 100755
--- a/src/pages/signin/SignInPageLayout/SignInPageLayoutNarrow.js
+++ b/src/pages/signin/SignInPageLayout/SignInPageLayoutNarrow.js
@@ -16,6 +16,9 @@ const propTypes = {
* on form type (set password, sign in, etc.) */
welcomeText: PropTypes.string.isRequired,
+ /** Whether to show welcome text on a particular page */
+ shouldShowWelcomeText: PropTypes.bool.isRequired,
+
...withLocalizePropTypes,
};
@@ -47,9 +50,11 @@ const SignInPageLayoutNarrow = props => (
height={variables.componentSizeLarge}
/>
-
- {props.welcomeText}
-
+ {props.shouldShowWelcomeText && (
+
+ {props.welcomeText}
+
+ )}
{props.children}
diff --git a/src/pages/signin/SignInPageLayout/SignInPageLayoutWide.js b/src/pages/signin/SignInPageLayout/SignInPageLayoutWide.js
index 383bd61c7fd..a1089721e35 100755
--- a/src/pages/signin/SignInPageLayout/SignInPageLayoutWide.js
+++ b/src/pages/signin/SignInPageLayout/SignInPageLayoutWide.js
@@ -20,6 +20,9 @@ const propTypes = {
/* Flag to check medium screen with device */
isMediumScreenWidth: PropTypes.bool.isRequired,
+ /** Whether to show welcome text on a particular page */
+ shouldShowWelcomeText: PropTypes.bool.isRequired,
+
...withLocalizePropTypes,
};
@@ -46,9 +49,11 @@ const SignInPageLayoutWide = (props) => {
height={variables.componentSizeLarge}
/>
-
- {props.welcomeText}
-
+ {props.shouldShowWelcomeText && (
+
+ {props.welcomeText}
+
+ )}
{props.children}
diff --git a/src/pages/signin/SignInPageLayout/index.js b/src/pages/signin/SignInPageLayout/index.js
index 1a5a88a4c7a..813b051de25 100644
--- a/src/pages/signin/SignInPageLayout/index.js
+++ b/src/pages/signin/SignInPageLayout/index.js
@@ -1,9 +1,20 @@
import React from 'react';
+import PropTypes from 'prop-types';
import SignInPageLayoutNarrow from './SignInPageLayoutNarrow';
import SignInPageLayoutWide from './SignInPageLayoutWide';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions';
const propTypes = {
+ /** The children to show inside the layout */
+ children: PropTypes.node.isRequired,
+
+ /** Welcome text to show in the header of the form, changes depending
+ * on form type (set password, sign in, etc.) */
+ welcomeText: PropTypes.string.isRequired,
+
+ /** Whether to show welcome text on a particular page */
+ shouldShowWelcomeText: PropTypes.bool.isRequired,
+
...windowDimensionsPropTypes,
};
@@ -13,11 +24,19 @@ const SignInPageLayout = props => (
{props.children}
)
- : {props.children}
+ : (
+
+ {props.children}
+
+ )
);
SignInPageLayout.propTypes = propTypes;
diff --git a/src/pages/workspace/WorkspaceBankAccountPage.js b/src/pages/workspace/WorkspaceBankAccountPage.js
new file mode 100644
index 00000000000..bdf0f28f0ad
--- /dev/null
+++ b/src/pages/workspace/WorkspaceBankAccountPage.js
@@ -0,0 +1,122 @@
+import lodashGet from 'lodash/get';
+import React from 'react';
+import {withOnyx} from 'react-native-onyx';
+import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
+import {Bank} from '../../components/Icon/Expensicons';
+import {BankArrowPink} from '../../components/Icon/Illustrations';
+import ScreenWrapper from '../../components/ScreenWrapper';
+import Text from '../../components/Text';
+import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
+import compose from '../../libs/compose';
+import BankAccount from '../../libs/models/BankAccount';
+import Navigation from '../../libs/Navigation/Navigation';
+import ONYXKEYS from '../../ONYXKEYS';
+import ROUTES from '../../ROUTES';
+import reimbursementAccountPropTypes from '../ReimbursementAccount/reimbursementAccountPropTypes';
+import WorkspaceSection from './WorkspaceSection';
+
+const propTypes = {
+ /** ACH data for the withdrawal account actively being set up */
+ reimbursementAccount: reimbursementAccountPropTypes,
+
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ reimbursementAccount: {
+ loading: true,
+ },
+};
+
+class WorkspaceBankAccountPage extends React.Component {
+ constructor(props) {
+ super(props);
+ this.onScreenFocus = this.onScreenFocus.bind(this);
+ this.getShouldShowPage = this.getShouldShowPage.bind(this);
+ this.navigateToBankAccountRoute = this.navigateToBankAccountRoute.bind(this);
+ }
+
+ componentDidMount() {
+ this.unsubscribe = this.props.navigation.addListener('focus', this.onScreenFocus);
+ }
+
+ componentWillUnmount() {
+ if (!this.unsubscribe) {
+ return;
+ }
+
+ this.unsubscribe();
+ }
+
+ /**
+ * When we are returning to this screen we want to check if we should go back or show the alternate view with "Continue with setup" button.
+ */
+ onScreenFocus() {
+ if (this.getShouldShowPage()) {
+ return;
+ }
+
+ this.props.navigation.goBack();
+ }
+
+ /**
+ * If we have an open bank account or no bank account at all then we will immediately redirect the user to /bank-account to display the next step
+ *
+ * @returns {Boolean}
+ */
+ getShouldShowPage() {
+ const state = lodashGet(this.props.reimbursementAccount, 'achData.state');
+ return lodashGet(this.props.reimbursementAccount, 'achData.bankAccountID') && state !== BankAccount.STATE.OPEN;
+ }
+
+ /**
+ * Navigate to the bank account route
+ */
+ navigateToBankAccountRoute() {
+ Navigation.navigate(ROUTES.getBankAccountRoute());
+ }
+
+ render() {
+ if (!this.getShouldShowPage()) {
+ this.navigateToBankAccountRoute();
+ return null;
+ }
+
+ return (
+
+ Navigation.navigate(ROUTES.getWorkspaceInitialRoute(this.props.route.params.policyID))}
+ shouldShowBackButton
+ />
+
+
+ {this.props.translate('workspace.bankAccount.youreAlmostDone')}
+
+
+
+ );
+ }
+}
+
+WorkspaceBankAccountPage.propTypes = propTypes;
+WorkspaceBankAccountPage.defaultProps = defaultProps;
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ reimbursementAccount: {
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ },
+ }),
+)(WorkspaceBankAccountPage);
diff --git a/src/pages/workspace/WorkspaceCardPage.js b/src/pages/workspace/WorkspaceCardPage.js
deleted file mode 100644
index 4eee5d7f953..00000000000
--- a/src/pages/workspace/WorkspaceCardPage.js
+++ /dev/null
@@ -1,230 +0,0 @@
-import React from 'react';
-import {
- View, ScrollView, StyleSheet,
-} from 'react-native';
-import PropTypes from 'prop-types';
-import {withOnyx} from 'react-native-onyx';
-import lodashGet from 'lodash/get';
-import styles from '../../styles/styles';
-import ONYXKEYS from '../../ONYXKEYS';
-import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
-import Navigation from '../../libs/Navigation/Navigation';
-import ScreenWrapper from '../../components/ScreenWrapper';
-import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
-import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions';
-import compose from '../../libs/compose';
-import Text from '../../components/Text';
-import Button from '../../components/Button';
-import variables from '../../styles/variables';
-import themeDefault from '../../styles/themes/default';
-import ROUTES from '../../ROUTES';
-import CONST from '../../CONST';
-import Permissions from '../../libs/Permissions';
-import HeroCardWebImage from '../../../assets/images/cascading-cards-web.svg';
-import HeroCardMobileImage from '../../../assets/images/cascading-cards-mobile.svg';
-import BankAccount from '../../libs/models/BankAccount';
-import {openSignedInLink} from '../../libs/actions/App';
-import {setWorkspaceIDForReimbursementAccount} from '../../libs/actions/BankAccounts';
-import reimbursementAccountPropTypes from '../ReimbursementAccount/reimbursementAccountPropTypes';
-
-const propTypes = {
- /* Onyx Props */
-
- /** Beta features list */
- betas: PropTypes.arrayOf(PropTypes.string).isRequired,
-
- /** The details about the user that is signed in */
- user: PropTypes.shape({
- /** Whether or not the user has public domain */
- isFromPublicDomain: PropTypes.bool,
-
- /** Whether the user is using Expensify Card */
- isUsingExpensifyCard: PropTypes.bool,
- }),
-
- /** URL Route params */
- route: PropTypes.shape({
- /** Params from the URL path */
- params: PropTypes.shape({
- /** policyID passed via route: /workspace/:policyID/people */
- policyID: PropTypes.string,
- }),
- }).isRequired,
-
- /** Bank account currently in setup */
- reimbursementAccount: reimbursementAccountPropTypes,
-
- ...withLocalizePropTypes,
- ...windowDimensionsPropTypes,
-};
-
-const defaultProps = {
- user: {
- isFromPublicDomain: false,
- isUsingExpensifyCard: false,
- },
- reimbursementAccount: {
- loading: false,
- },
-};
-
-const WorkspaceCardPage = ({
- betas,
- user,
- translate,
- route,
- isSmallScreenWidth,
- isMediumScreenWidth,
- reimbursementAccount,
-}) => {
- const isVerifying = lodashGet(reimbursementAccount, 'achData.state', '') === BankAccount.STATE.VERIFYING;
- const isPending = lodashGet(reimbursementAccount, 'achData.state', '') === BankAccount.STATE.PENDING;
- const isNotAutoProvisioned = !user.isUsingExpensifyCard
- && lodashGet(reimbursementAccount, 'achData.state', '') === BankAccount.STATE.OPEN;
- let buttonText;
-
- const openBankSetupModal = () => {
- setWorkspaceIDForReimbursementAccount(route.params.policyID);
- Navigation.navigate(ROUTES.getBankAccountRoute());
- };
-
- if (user.isFromPublicDomain) {
- buttonText = translate('workspace.card.addEmail');
- } else if (user.isUsingExpensifyCard) {
- buttonText = translate('workspace.card.manageCards');
- } else if (isVerifying || isPending || isNotAutoProvisioned) {
- buttonText = translate('workspace.card.finishSetup');
- openBankSetupModal();
- } else {
- buttonText = translate('workspace.card.getStarted');
- }
-
- const onPress = () => {
- if (user.isFromPublicDomain) {
- openSignedInLink(CONST.ADD_SECONDARY_LOGIN_URL);
- } else if (user.isUsingExpensifyCard) {
- openSignedInLink(CONST.MANAGE_CARDS_URL);
- } else {
- openBankSetupModal();
- }
- };
-
- if (!Permissions.canUseFreePlan(betas)) {
- console.debug('Not showing workspace card page because user is not on free plan beta');
- return ;
- }
-
- return (
-
- Navigation.dismissModal()}
- onBackButtonPress={() => Navigation.goBack()}
- shouldShowBackButton={isSmallScreenWidth}
- shouldShowInboxCallButton
- inboxCallTaskID="WorkspaceCompanyCards"
- />
-
-
-
- {isSmallScreenWidth || isMediumScreenWidth
- ? (
-
- )
- : (
-
- )}
-
-
-
-
- {user.isUsingExpensifyCard
- ? translate('workspace.card.cardReadyTagline')
- : translate('workspace.card.tagline')}
-
-
- {user.isFromPublicDomain
- ? translate('workspace.card.publicCopy')
- : translate('workspace.card.privateCopy')}
-
-
-
-
-
-
-
-
- );
-};
-
-WorkspaceCardPage.propTypes = propTypes;
-WorkspaceCardPage.defaultProps = defaultProps;
-WorkspaceCardPage.displayName = 'WorkspaceCardPage';
-
-export default compose(
- withLocalize,
- withWindowDimensions,
- withOnyx({
- user: {
- key: ONYXKEYS.USER,
- },
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
- betas: {
- key: ONYXKEYS.BETAS,
- },
- }),
-)(WorkspaceCardPage);
diff --git a/src/pages/workspace/WorkspaceEditorPage.js b/src/pages/workspace/WorkspaceEditorPage.js
deleted file mode 100644
index c5f3f77b196..00000000000
--- a/src/pages/workspace/WorkspaceEditorPage.js
+++ /dev/null
@@ -1,172 +0,0 @@
-import React from 'react';
-import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import PropTypes from 'prop-types';
-import lodashGet from 'lodash/get';
-import _ from 'underscore';
-import ONYXKEYS from '../../ONYXKEYS';
-import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
-import ScreenWrapper from '../../components/ScreenWrapper';
-import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
-import Navigation from '../../libs/Navigation/Navigation';
-import Permissions from '../../libs/Permissions';
-import styles from '../../styles/styles';
-import TextInputWithLabel from '../../components/TextInputWithLabel';
-import Button from '../../components/Button';
-import Text from '../../components/Text';
-import compose from '../../libs/compose';
-import {
- uploadAvatar, update, updateLocalPolicyValues,
-} from '../../libs/actions/Policy';
-import Icon from '../../components/Icon';
-import {Workspace} from '../../components/Icon/Expensicons';
-import AvatarWithImagePicker from '../../components/AvatarWithImagePicker';
-import defaultTheme from '../../styles/themes/default';
-import Growl from '../../libs/Growl';
-import CONST from '../../CONST';
-
-const propTypes = {
- /** List of betas */
- betas: PropTypes.arrayOf(PropTypes.string),
-
- ...withLocalizePropTypes,
-};
-const defaultProps = {
- betas: [],
-};
-
-class WorkspaceEditorPage extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- name: props.policy.name,
- avatarURL: props.policy.avatarURL,
- previewAvatarURL: props.policy.avatarURL,
- };
-
- this.submit = this.submit.bind(this);
- this.onImageSelected = this.onImageSelected.bind(this);
- this.onImageRemoved = this.onImageRemoved.bind(this);
- this.uploadAvatarPromise = Promise.resolve();
- }
-
- onImageSelected(image) {
- updateLocalPolicyValues(this.props.policy.id, {isAvatarUploading: true});
- this.setState({previewAvatarURL: image.uri});
-
- // Store the upload avatar promise so we can wait for it to finish before updating the policy
- this.uploadAvatarPromise = uploadAvatar(image).then(url => new Promise((resolve) => {
- this.setState({avatarURL: url}, resolve);
- })).catch(() => {
- Growl.error(this.props.translate('workspace.editor.avatarUploadFailureMessage'));
- }).finally(() => updateLocalPolicyValues(this.props.policy.id, {isAvatarUploading: false}));
- }
-
- onImageRemoved() {
- this.setState({previewAvatarURL: '', avatarURL: ''});
- }
-
- submit() {
- updateLocalPolicyValues(this.props.policy.id, {isPolicyUpdating: true});
-
- // Wait for the upload avatar promise to finish before updating the policy
- this.uploadAvatarPromise.then(() => {
- const name = this.state.name.trim();
- const avatarURL = this.state.avatarURL;
- const policyID = this.props.policy.id;
-
- update(policyID, {name, avatarURL});
- }).catch(() => {
- updateLocalPolicyValues(this.props.policy.id, {isPolicyUpdating: false});
- });
- }
-
- render() {
- const {policy} = this.props;
-
- if (!Permissions.canUseFreePlan(this.props.betas)) {
- console.debug('Not showing workspace editor page because user is not on free plan beta');
- return ;
- }
-
- if (_.isEmpty(policy)) {
- return null;
- }
-
- const isButtonDisabled = policy.isAvatarUploading
- || (this.state.avatarURL === this.props.policy.avatarURL
- && this.state.name === this.props.policy.name);
- return (
-
-
-
-
-
- (
-
- )}
- style={[styles.mb3]}
- anchorPosition={{top: 172, right: 18}}
- isUsingDefaultAvatar={!this.state.previewAvatarURL}
- onImageSelected={this.onImageSelected}
- onImageRemoved={this.onImageRemoved}
- />
-
- this.setState({name})}
- onSubmitEditting={this.submit}
- />
-
- {this.props.translate('workspace.editor.nameInputHelpText')}
-
-
-
-
-
-
- );
- }
-}
-
-WorkspaceEditorPage.propTypes = propTypes;
-WorkspaceEditorPage.defaultProps = defaultProps;
-
-export default compose(
- withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
- },
- policy: {
- key: (props) => {
- const routes = lodashGet(props.navigation.getState(), 'routes', []);
- const routeWithPolicyIDParam = _.find(routes, route => route.params && route.params.policyID);
- const policyID = lodashGet(routeWithPolicyIDParam, ['params', 'policyID']);
- return `${ONYXKEYS.COLLECTION.POLICY}${policyID}`;
- },
- },
- }),
- withLocalize,
-)(WorkspaceEditorPage);
diff --git a/src/pages/workspace/WorkspaceSidebar.js b/src/pages/workspace/WorkspaceInitialPage.js
similarity index 68%
rename from src/pages/workspace/WorkspaceSidebar.js
rename to src/pages/workspace/WorkspaceInitialPage.js
index ad5c99d86e8..76c86b9feab 100644
--- a/src/pages/workspace/WorkspaceSidebar.js
+++ b/src/pages/workspace/WorkspaceInitialPage.js
@@ -9,12 +9,18 @@ import Navigation from '../../libs/Navigation/Navigation';
import ROUTES from '../../ROUTES';
import styles from '../../styles/styles';
import Text from '../../components/Text';
+import Tooltip from '../../components/Tooltip';
import Icon from '../../components/Icon';
import {
- Users,
+ Bank,
+ Gear,
ExpensifyCard,
+ Receipt,
+ Users,
Workspace,
- Pencil,
+ Bill,
+ Invoice,
+ Luggage,
} from '../../components/Icon/Expensicons';
import ScreenWrapper from '../../components/ScreenWrapper';
import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
@@ -23,12 +29,9 @@ import themedefault from '../../styles/themes/default';
import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions';
import compose from '../../libs/compose';
-import Growl from '../../libs/Growl';
import ONYXKEYS from '../../ONYXKEYS';
import Avatar from '../../components/Avatar';
-import CONST from '../../CONST';
-import Tooltip from '../../components/Tooltip';
-import variables from '../../styles/variables';
+import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator';
const propTypes = {
/** Whether the current screen is focused. */
@@ -43,57 +46,82 @@ const propTypes = {
name: PropTypes.string,
}),
- /** All the polices that we have loaded in Onyx */
- allPolicies: PropTypes.shape({
- /** ID of the policy */
- id: PropTypes.string,
- }),
-
...withLocalizePropTypes,
...windowDimensionsPropTypes,
};
const defaultProps = {
policy: {},
- allPolicies: null,
};
-const WorkspaceSidebar = ({
- translate, isSmallScreenWidth, policy, allPolicies, isFocused,
+const WorkspaceInitialPage = ({
+ translate, isSmallScreenWidth, policy, isFocused,
}) => {
+ if (_.isEmpty(policy)) {
+ return ;
+ }
+
const menuItems = [
+ {
+ translationKey: 'workspace.common.settings',
+ icon: Gear,
+ action: () => Navigation.navigate(ROUTES.getWorkspaceSettingsRoute(policy.id)),
+ isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceSettingsRoute(policy.id)),
+ },
{
translationKey: 'workspace.common.card',
icon: ExpensifyCard,
- action: () => {
- Navigation.navigate(ROUTES.getWorkspaceCardRoute(policy.id));
- },
+ action: () => Navigation.navigate(ROUTES.getWorkspaceCardRoute(policy.id)),
isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceCardRoute(policy.id)),
},
{
- translationKey: 'common.people',
+ translationKey: 'workspace.common.reimburse',
+ icon: Receipt,
+ action: () => Navigation.navigate(ROUTES.getWorkspaceReimburseRoute(policy.id)),
+ isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceReimburseRoute(policy.id)),
+ },
+ {
+ translationKey: 'workspace.common.bills',
+ icon: Bill,
+ action: () => Navigation.navigate(ROUTES.getWorkspaceBillsRoute(policy.id)),
+ isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceBillsRoute(policy.id)),
+ },
+ {
+ translationKey: 'workspace.common.invoices',
+ icon: Invoice,
+ action: () => Navigation.navigate(ROUTES.getWorkspaceInvoicesRoute(policy.id)),
+ isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceInvoicesRoute(policy.id)),
+ },
+ {
+ translationKey: 'workspace.common.travel',
+ icon: Luggage,
+ action: () => Navigation.navigate(ROUTES.getWorkspaceTravelRoute(policy.id)),
+ isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceTravelRoute(policy.id)),
+ },
+ {
+ translationKey: 'workspace.common.members',
icon: Users,
- action: () => {
- Navigation.navigate(ROUTES.getWorkspacePeopleRoute(policy.id));
- },
- isActive: Navigation.isActiveRoute(ROUTES.getWorkspacePeopleRoute(policy.id)),
+ action: () => Navigation.navigate(ROUTES.getWorkspaceMembersRoute(policy.id)),
+ isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceMembersRoute(policy.id)),
+ },
+ {
+ translationKey: 'workspace.common.bankAccount',
+ icon: Bank,
+ action: () => Navigation.navigate(ROUTES.getWorkspaceBankAccountRoute(policy.id)),
+ isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceBankAccountRoute(policy.id)),
},
];
- // After all the policies have loaded, we can know if the given policyID points to a nonexistant workspace
- // When free plan is out of beta and Permissions.canUseFreePlan() gets removed,
- // all code involving 'allPolicies' can be removed since policy loading will no longer be delayed on login.
- if (allPolicies !== null && _.isEmpty(policy)) {
- Growl.error(translate('workspace.error.growlMessageInvalidPolicy'), CONST.GROWL.DURATION_LONG);
- Navigation.dismissModal();
- return null;
- }
-
-
- const openEditor = () => Navigation.navigate(ROUTES.getWorkspaceEditorRoute(policy.id));
+ const openEditor = () => Navigation.navigate(ROUTES.getWorkspaceSettingsRoute(policy.id));
return (
+ Navigation.navigate(ROUTES.SETTINGS)}
+ onCloseButtonPress={() => Navigation.dismissModal()}
+ />
- {isSmallScreenWidth
- && (
- Navigation.dismissModal()}
- />
- )}
)}
-
-
-
-
-
0 || lodashGet(this.props.policy, 'alertMessage', '').length > 0;
+ }
+
+ clearErrors() {
+ setWorkspaceErrors(this.props.route.params.policyID, {});
+ hideWorkspaceAlertMessage(this.props.route.params.policyID);
+ }
+
focusEmailOrPhoneInput() {
if (!this.emailOrPhoneInputRef) {
return;
@@ -84,29 +125,40 @@ class WorkspaceInvitePage extends React.Component {
* Handle the invite button click
*/
inviteUser() {
+ if (!this.validate()) {
+ return;
+ }
+
+ const logins = _.map(_.compact(this.state.userLogins.split(',')), login => login.trim());
+ invite(logins, this.state.welcomeNote || this.getWelcomeNotePlaceholder(),
+ this.props.route.params.policyID);
+ }
+
+ /**
+ * @returns {Boolean}
+ */
+ validate() {
const logins = _.map(_.compact(this.state.userLogins.split(',')), login => login.trim());
const isEnteredLoginsvalid = _.every(logins, login => Str.isValidEmail(login) || Str.isValidPhone(login));
- if (!isEnteredLoginsvalid) {
- Growl.error(this.props.translate('workspace.invite.pleaseEnterValidLogin'), 5000);
- return;
+ const errors = {};
+ let foundSystemLogin = '';
+ if (logins.length <= 0 || !isEnteredLoginsvalid) {
+ errors.invalidLogin = true;
}
- const foundSystemLogin = _.find(logins, login => isSystemUser(login));
+ foundSystemLogin = _.find(logins, login => isSystemUser(login));
if (foundSystemLogin) {
- Growl.error(this.props.translate('workspace.invite.systemUserError', {email: foundSystemLogin}), 5000);
- return;
+ errors.systemUserError = true;
}
const policyEmployeeList = lodashGet(this.props, 'policy.employeeList', []);
const areLoginsDuplicate = _.some(logins, login => _.contains(policyEmployeeList, addSMSDomainIfPhoneNumber(login)));
if (areLoginsDuplicate) {
- Growl.error(this.props.translate('workspace.invite.pleaseEnterUniqueLogin'), 5000);
- return;
+ errors.duplicateLogin = true;
}
- invite(logins, this.state.welcomeNote || this.getWelcomeNotePlaceholder(),
- this.props.route.params.policyID);
- Navigation.goBack();
+ this.setState({foundSystemLogin}, () => setWorkspaceErrors(this.props.route.params.policyID, errors));
+ return _.size(errors) <= 0;
}
render() {
@@ -115,52 +167,97 @@ class WorkspaceInvitePage extends React.Component {
{
+ this.clearErrors();
+ Navigation.dismissModal();
+ }}
+ shouldShowBackButton
+ onBackButtonPress={() => Navigation.goBack()}
/>
-
-
- {this.props.translate('workspace.invite.invitePeoplePrompt')}
-
-
- this.emailOrPhoneInputRef = el}
- label={this.props.translate('workspace.invite.enterEmailOrPhone')}
- placeholder={this.props.translate('workspace.invite.EmailOrPhonePlaceholder')}
- autoCompleteType="email"
- autoCorrect={false}
- autoCapitalize="none"
- multiline
- numberOfLines={2}
- value={this.state.userLogins}
- onChangeText={text => this.setState({userLogins: text})}
- />
-
-
- this.setState({welcomeNote: text})}
- />
-
- {this.props.translate('common.privacy')}
-
+ this.form = el}
+ contentContainerStyle={styles.flexGrow1}
+ keyboardShouldPersistTaps="handled"
+ >
+ {/* Form elements */}
+
+
+ {this.props.translate('workspace.invite.invitePeoplePrompt')}
+
+
+ this.emailOrPhoneInputRef = el}
+ label={this.props.translate('workspace.invite.enterEmailOrPhone')}
+ placeholder={this.props.translate('workspace.invite.EmailOrPhonePlaceholder')}
+ autoCompleteType="email"
+ autoCorrect={false}
+ autoCapitalize="none"
+ multiline
+ numberOfLines={2}
+ value={this.state.userLogins}
+ onChangeText={(text) => {
+ this.clearErrors();
+ this.setState({userLogins: text, foundSystemLogin: ''});
+ }}
+ errorText={this.getErrorText()}
+ />
+
+
+ this.setState({welcomeNote: text})}
+ />
+
+ {
+ e.preventDefault();
+ openExternalLink(CONST.PRIVACY_URL);
+ }}
+ accessibilityRole="link"
+ href={CONST.PRIVACY_URL}
+ >
+ {({hovered, pressed}) => (
+
+
+ {this.props.translate('common.privacyPolicy')}
+
+
+
+
+
+ )}
+
+
+
-
-
- {
+ this.form.scrollTo({y: 0, animated: true});
+ }}
+ message={this.props.policy.alertMessage}
/>
-
+
);
diff --git a/src/pages/workspace/WorkspacePeoplePage.js b/src/pages/workspace/WorkspaceMembersPage.js
similarity index 65%
rename from src/pages/workspace/WorkspacePeoplePage.js
rename to src/pages/workspace/WorkspaceMembersPage.js
index 843ef6be372..8543d777bc6 100644
--- a/src/pages/workspace/WorkspacePeoplePage.js
+++ b/src/pages/workspace/WorkspaceMembersPage.js
@@ -24,6 +24,8 @@ import personalDetailsPropType from '../personalDetailsPropType';
import Permissions from '../../libs/Permissions';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions';
import OptionRow from '../home/sidebar/OptionRow';
+import CheckboxWithTooltip from '../../components/CheckboxWithTooltip';
+import Hoverable from '../../components/Hoverable';
const propTypes = {
...withLocalizePropTypes,
@@ -46,7 +48,7 @@ const propTypes = {
route: PropTypes.shape({
/** Params from the URL path */
params: PropTypes.shape({
- /** policyID passed via route: /workspace/:policyID/people */
+ /** policyID passed via route: /workspace/:policyID/members */
policyID: PropTypes.string,
}),
}).isRequired,
@@ -58,13 +60,14 @@ const defaultProps = {
},
};
-class WorkspacePeoplePage extends React.Component {
+class WorkspaceMembersPage extends React.Component {
constructor(props) {
super(props);
this.state = {
selectedEmployees: [],
isRemoveMembersConfirmModalVisible: false,
+ showTooltipForLogin: '',
};
this.renderItem = this.renderItem.bind(this);
@@ -112,9 +115,11 @@ class WorkspacePeoplePage extends React.Component {
* Add or remove all users from the selectedEmployees list
*/
toggleAllUsers() {
+ this.setState({showTooltipForLogin: ''});
+ const removableMembers = _.without(this.props.policy.employeeList, this.props.session.email, this.props.policy.owner);
this.setState(prevState => ({
- selectedEmployees: this.props.policy.employeeList.length !== prevState.selectedEmployees.length
- ? this.props.policy.employeeList
+ selectedEmployees: removableMembers.length !== prevState.selectedEmployees.length
+ ? removableMembers
: [],
}));
}
@@ -125,11 +130,39 @@ class WorkspacePeoplePage extends React.Component {
* @param {String} login
*/
toggleUser(login) {
+ if (this.willTooltipShowForLogin(login)) {
+ return;
+ }
+
+ // Add or remove the user if the checkbox is enabled and is clickable.
if (_.contains(this.state.selectedEmployees, login)) {
this.removeUser(login);
} else {
this.addUser(login);
}
+
+ this.setState({showTooltipForLogin: ''});
+ }
+
+ /**
+ * Shows the tooltip for non removable members
+ *
+ * @param {String} login
+ * @param {Boolean} wasHovered
+ * @returns {Boolean} Return true if the tooltip was displayed so we can use the state of it in other functions.
+ */
+ willTooltipShowForLogin(login, wasHovered = false) {
+ // Small screens only show the tooltip on press, so ignore hovered event on those cases.
+ if (wasHovered && (this.props.isSmallScreenWidth || this.props.isMediumScreenWidth)) {
+ return false;
+ }
+
+ const canBeRemoved = this.props.policy.owner !== login && this.props.session.email !== login;
+ if (!canBeRemoved) {
+ this.setState({showTooltipForLogin: login});
+ }
+
+ return !canBeRemoved;
}
/**
@@ -169,41 +202,46 @@ class WorkspacePeoplePage extends React.Component {
renderItem({
item,
}) {
+ const canBeRemoved = this.props.policy.owner !== item.login && this.props.session.email !== item.login;
return (
- this.toggleUser(item.login)}
- activeOpacity={0.7}
- >
-
- this.willTooltipShowForLogin(item.login, true)} onHoverOut={() => this.setState({showTooltipForLogin: ''})}>
+ this.toggleUser(item.login)}
+ activeOpacity={0.7}
+ >
+ this.toggleUser(item.login)}
+ toggleTooltip={this.state.showTooltipForLogin === item.login}
+ text={this.props.translate('workspace.people.error.cannotRemove')}
/>
-
-
-
-
- {this.props.session.email === item.login && (
-
-
-
- {this.props.translate('common.admin')}
-
-
+
+
- )}
-
+ {this.props.session.email === item.login && (
+
+
+
+ {this.props.translate('common.admin')}
+
+
+
+ )}
+
+
);
}
@@ -213,17 +251,21 @@ class WorkspacePeoplePage extends React.Component {
return ;
}
const policyEmployeeList = lodashGet(this.props, 'policy.employeeList', []);
+ const removableMembers = _.without(this.props.policy.employeeList, this.props.session.email, this.props.policy.owner);
const data = _.chain(policyEmployeeList)
.map(email => this.props.personalDetails[email])
.filter()
+ .sortBy(person => person.displayName.toLowerCase())
.value();
+ const policyID = lodashGet(this.props.route, 'params.policyID');
+
return (
Navigation.dismissModal()}
- onBackButtonPress={() => Navigation.goBack()}
- shouldShowBackButton={this.props.isSmallScreenWidth}
+ onBackButtonPress={() => Navigation.navigate(ROUTES.getWorkspaceInitialRoute(policyID))}
+ shouldShowBackButton
/>
-
+
this.inviteUser()}
/>
-
+
this.toggleAllUsers()}
/>
@@ -268,6 +312,7 @@ class WorkspacePeoplePage extends React.Component {
renderItem={this.renderItem}
data={data}
keyExtractor={item => item.login}
+ showsVerticalScrollIndicator={false}
/>
@@ -276,9 +321,9 @@ class WorkspacePeoplePage extends React.Component {
}
}
-WorkspacePeoplePage.propTypes = propTypes;
-WorkspacePeoplePage.defaultProps = defaultProps;
-WorkspacePeoplePage.displayName = 'WorkspacePeoplePage';
+WorkspaceMembersPage.propTypes = propTypes;
+WorkspaceMembersPage.defaultProps = defaultProps;
+WorkspaceMembersPage.displayName = 'WorkspacePeoplePage';
export default compose(
withLocalize,
@@ -297,4 +342,4 @@ export default compose(
key: ONYXKEYS.BETAS,
},
}),
-)(WorkspacePeoplePage);
+)(WorkspaceMembersPage);
diff --git a/src/pages/workspace/WorkspacePageWithSections.js b/src/pages/workspace/WorkspacePageWithSections.js
new file mode 100644
index 00000000000..07ff09ff8b0
--- /dev/null
+++ b/src/pages/workspace/WorkspacePageWithSections.js
@@ -0,0 +1,95 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {View, ScrollView} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import lodashGet from 'lodash/get';
+import styles from '../../styles/styles';
+import Navigation from '../../libs/Navigation/Navigation';
+import compose from '../../libs/compose';
+import ROUTES from '../../ROUTES';
+import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
+import ScreenWrapper from '../../components/ScreenWrapper';
+import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
+import ONYXKEYS from '../../ONYXKEYS';
+import {fetchFreePlanVerifiedBankAccount} from '../../libs/actions/BankAccounts';
+import BankAccount from '../../libs/models/BankAccount';
+import reimbursementAccountPropTypes from '../ReimbursementAccount/reimbursementAccountPropTypes';
+import userPropTypes from '../settings/userPropTypes';
+
+const propTypes = {
+ /** The text to display in the header */
+ headerText: PropTypes.string.isRequired,
+
+ /** The route object passed to this page from the navigator */
+ route: PropTypes.shape({
+ /** Each parameter passed via the URL */
+ params: PropTypes.shape({
+ /** The policyID that is being configured */
+ policyID: PropTypes.string.isRequired,
+ }).isRequired,
+ }).isRequired,
+
+ /** From Onyx */
+ reimbursementAccount: reimbursementAccountPropTypes,
+ user: userPropTypes,
+
+ children: PropTypes.func,
+
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ children: () => {},
+ user: {},
+ reimbursementAccount: {},
+};
+
+class WorkspacePageWithSections extends React.Component {
+ componentDidMount() {
+ const achState = lodashGet(this.props.reimbursementAccount, 'achData.state', '');
+ fetchFreePlanVerifiedBankAccount('', achState);
+ }
+
+ render() {
+ const achState = lodashGet(this.props.reimbursementAccount, 'achData.state', '');
+ const hasVBA = achState === BankAccount.STATE.OPEN;
+ const isUsingECard = lodashGet(this.props.user, 'isUsingExpensifyCard', false);
+ const policyID = lodashGet(this.props.route, 'params.policyID');
+
+ return (
+
+ Navigation.navigate(ROUTES.getWorkspaceInitialRoute(policyID))}
+ onCloseButtonPress={() => Navigation.dismissModal()}
+ />
+
+
+
+ {this.props.children(hasVBA, policyID, isUsingECard)}
+
+
+
+
+ );
+ }
+}
+
+WorkspacePageWithSections.propTypes = propTypes;
+WorkspacePageWithSections.defaultProps = defaultProps;
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ user: {
+ key: ONYXKEYS.USER,
+ },
+ reimbursementAccount: {
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ },
+ }),
+)(WorkspacePageWithSections);
diff --git a/src/pages/workspace/WorkspaceSection.js b/src/pages/workspace/WorkspaceSection.js
new file mode 100644
index 00000000000..4511b517c9a
--- /dev/null
+++ b/src/pages/workspace/WorkspaceSection.js
@@ -0,0 +1,66 @@
+import React from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import Text from '../../components/Text';
+import styles from '../../styles/styles';
+import MenuItemList from '../../components/MenuItemList';
+import Icon from '../../components/Icon';
+import menuItemPropTypes from '../../components/menuItemPropTypes';
+
+const propTypes = {
+ /** An array of props that are pass to individual MenuItem components */
+ menuItems: PropTypes.arrayOf(PropTypes.shape(menuItemPropTypes)),
+
+ /** The text to display in the title of the section */
+ title: PropTypes.string.isRequired,
+
+ /** The icon to display along with the title */
+ icon: PropTypes.func,
+
+ /** Icon component */
+ IconComponent: PropTypes.func,
+
+ /** Contents to display inside the section */
+ children: PropTypes.node,
+};
+
+const defaultProps = {
+ menuItems: null,
+ children: null,
+ icon: null,
+ IconComponent: null,
+};
+
+const WorkspaceSection = ({
+ menuItems,
+ title,
+ icon,
+ children,
+ IconComponent,
+}) => (
+ <>
+
+
+
+ {title}
+
+
+ {icon && }
+ {IconComponent && }
+
+
+
+
+ {children}
+
+
+
+ {menuItems && }
+ >
+);
+
+WorkspaceSection.displayName = 'WorkspaceSection';
+WorkspaceSection.propTypes = propTypes;
+WorkspaceSection.defaultProps = defaultProps;
+
+export default WorkspaceSection;
diff --git a/src/pages/workspace/WorkspaceSettingsPage.js b/src/pages/workspace/WorkspaceSettingsPage.js
new file mode 100644
index 00000000000..53db39638bf
--- /dev/null
+++ b/src/pages/workspace/WorkspaceSettingsPage.js
@@ -0,0 +1,220 @@
+import React from 'react';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import PropTypes from 'prop-types';
+import lodashGet from 'lodash/get';
+import _ from 'underscore';
+import ONYXKEYS from '../../ONYXKEYS';
+import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
+import Navigation from '../../libs/Navigation/Navigation';
+import Permissions from '../../libs/Permissions';
+import styles from '../../styles/styles';
+import Button from '../../components/Button';
+import Text from '../../components/Text';
+import compose from '../../libs/compose';
+import * as Policy from '../../libs/actions/Policy';
+import Icon from '../../components/Icon';
+import {Workspace} from '../../components/Icon/Expensicons';
+import AvatarWithImagePicker from '../../components/AvatarWithImagePicker';
+import defaultTheme from '../../styles/themes/default';
+import Growl from '../../libs/Growl';
+import CONST from '../../CONST';
+import ExpensiPicker from '../../components/ExpensiPicker';
+import {getCurrencyList} from '../../libs/actions/PersonalDetails';
+import ExpensiTextInput from '../../components/ExpensiTextInput';
+import FixedFooter from '../../components/FixedFooter';
+import WorkspacePageWithSections from './WorkspacePageWithSections';
+import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator';
+
+const propTypes = {
+ /** List of betas */
+ betas: PropTypes.arrayOf(PropTypes.string),
+
+ /** Policy for the current route */
+ policy: PropTypes.shape({
+ /** ID of the policy */
+ id: PropTypes.string,
+
+ /** Name of the policy */
+ name: PropTypes.string.isRequired,
+
+ /** Avatar of the policy */
+ avatarURL: PropTypes.string.isRequired,
+
+ /** Currency of the policy */
+ outputCurrency: PropTypes.string.isRequired,
+ }).isRequired,
+
+ ...withLocalizePropTypes,
+};
+const defaultProps = {
+ betas: [],
+};
+
+class WorkspaceSettingsPage extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ name: props.policy.name,
+ avatarURL: props.policy.avatarURL,
+ previewAvatarURL: props.policy.avatarURL,
+ currency: props.policy.outputCurrency,
+ };
+
+ this.submit = this.submit.bind(this);
+ this.uploadAvatar = this.uploadAvatar.bind(this);
+ this.removeAvatar = this.removeAvatar.bind(this);
+ this.getCurrencyItems = this.getCurrencyItems.bind(this);
+ this.uploadAvatarPromise = Promise.resolve();
+ }
+
+ componentDidMount() {
+ getCurrencyList();
+ }
+
+ /**
+ * @returns {Object[]}
+ */
+ getCurrencyItems() {
+ const currencyListKeys = _.keys(this.props.currencyList);
+ return _.map(currencyListKeys, currencyCode => ({
+ value: currencyCode,
+ label: `${currencyCode} - ${this.props.currencyList[currencyCode].symbol}`,
+ }));
+ }
+
+ removeAvatar() {
+ this.setState({previewAvatarURL: '', avatarURL: ''});
+ }
+
+ /**
+ * @param {Object} image
+ * @param {String} image.uri
+ */
+ uploadAvatar(image) {
+ Policy.updateLocalPolicyValues(this.props.policy.id, {isAvatarUploading: true});
+ this.setState({previewAvatarURL: image.uri});
+
+ // Store the upload avatar promise so we can wait for it to finish before updating the policy
+ this.uploadAvatarPromise = Policy.uploadAvatar(image).then(url => new Promise((resolve) => {
+ this.setState({avatarURL: url}, resolve);
+ })).catch(() => {
+ Growl.error(this.props.translate('workspace.editor.avatarUploadFailureMessage'));
+ }).finally(() => Policy.updateLocalPolicyValues(this.props.policy.id, {isAvatarUploading: false}));
+ }
+
+ submit() {
+ Policy.updateLocalPolicyValues(this.props.policy.id, {isPolicyUpdating: true});
+
+ // Wait for the upload avatar promise to finish before updating the policy
+ this.uploadAvatarPromise.then(() => {
+ const name = this.state.name.trim();
+ const avatarURL = this.state.avatarURL;
+ const policyID = this.props.policy.id;
+ const currency = this.state.currency;
+
+ Policy.update(policyID, {name, avatarURL, outputCurrency: currency}, true);
+ }).catch(() => {
+ Policy.updateLocalPolicyValues(this.props.policy.id, {isPolicyUpdating: false});
+ });
+ }
+
+ render() {
+ const {policy} = this.props;
+
+ if (!Permissions.canUseFreePlan(this.props.betas)) {
+ console.debug('Not showing workspace editor page because user is not on free plan beta');
+ return ;
+ }
+
+ if (_.isEmpty(policy)) {
+ return ;
+ }
+
+ return (
+
+ {hasVBA => (
+ <>
+
+
+ (
+
+ )}
+ style={[styles.mb3]}
+ anchorPosition={{top: 172, right: 18}}
+ isUsingDefaultAvatar={!this.state.previewAvatarURL}
+ onImageSelected={this.uploadAvatar}
+ onImageRemoved={this.removeAvatar}
+ />
+
+ this.setState({name})}
+ value={this.state.name}
+ hasError={!this.state.name.trim().length}
+ errorText={this.state.name.trim().length ? '' : this.props.translate('workspace.editor.nameIsRequiredError')}
+ />
+
+
+ this.setState({currency})}
+ items={this.getCurrencyItems()}
+ value={this.state.currency}
+ isDisabled={hasVBA}
+ />
+
+
+ {this.props.translate('workspace.editor.currencyInputHelpText')}
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+ }
+}
+
+WorkspaceSettingsPage.propTypes = propTypes;
+WorkspaceSettingsPage.defaultProps = defaultProps;
+
+export default compose(
+ withOnyx({
+ betas: {
+ key: ONYXKEYS.BETAS,
+ },
+ policy: {
+ key: (props) => {
+ const policyID = lodashGet(props, 'route.params.policyID', '');
+ return `${ONYXKEYS.COLLECTION.POLICY}${policyID}`;
+ },
+ },
+ currencyList: {key: ONYXKEYS.CURRENCY_LIST},
+ }),
+ withLocalize,
+)(WorkspaceSettingsPage);
diff --git a/src/pages/workspace/bills/WorkspaceBillsFirstSection.js b/src/pages/workspace/bills/WorkspaceBillsFirstSection.js
new file mode 100644
index 00000000000..3b2cbdb47c5
--- /dev/null
+++ b/src/pages/workspace/bills/WorkspaceBillsFirstSection.js
@@ -0,0 +1,94 @@
+import React from 'react';
+import {View, TouchableOpacity} from 'react-native';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
+import Str from 'expensify-common/lib/str';
+import Text from '../../../components/Text';
+import styles from '../../../styles/styles';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import {
+ Bill,
+ NewWindow,
+} from '../../../components/Icon/Expensicons';
+import {InvoiceOrange} from '../../../components/Icon/Illustrations';
+import WorkspaceSection from '../WorkspaceSection';
+import CopyTextToClipboard from '../../../components/CopyTextToClipboard';
+import {openExternalLink, openOldDotLink} from '../../../libs/actions/Link';
+import compose from '../../../libs/compose';
+import ONYXKEYS from '../../../ONYXKEYS';
+import userPropTypes from '../../settings/userPropTypes';
+
+const propTypes = {
+ /** The policy ID currently being configured */
+ policyID: PropTypes.string.isRequired,
+
+ ...withLocalizePropTypes,
+
+ /* From Onyx */
+ /** Session of currently logged in user */
+ session: PropTypes.shape({
+ /** Email address */
+ email: PropTypes.string.isRequired,
+ }).isRequired,
+
+ /** Information about the logged in user's account */
+ user: userPropTypes.isRequired,
+};
+
+const WorkspaceBillsFirstSection = ({
+ translate,
+ policyID,
+ session,
+ user,
+}) => {
+ const emailDomain = Str.extractEmailDomain(session.email);
+ return (
+ openOldDotLink(`reports?policyID=${policyID}&from=all&type=bill&showStates=Open,Processing,Approved,Reimbursed,Archived&isAdvancedFilterMode=true`),
+ icon: Bill,
+ shouldShowRightIcon: true,
+ iconRight: NewWindow,
+ },
+ ]}
+ >
+
+
+ {translate('workspace.bills.askYourVendorsBeforeEmail')}
+ {user.isFromPublicDomain ? (
+ openExternalLink('https://community.expensify.com/discussion/7500/how-to-pay-your-company-bills-in-expensify/')}
+ >
+ example.com@expensify.cash
+
+ ) : (
+
+ )}
+ {translate('workspace.bills.askYourVendorsAfterEmail')}
+
+
+
+ );
+};
+
+WorkspaceBillsFirstSection.propTypes = propTypes;
+WorkspaceBillsFirstSection.displayName = 'WorkspaceBillsFirstSection';
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ user: {
+ key: ONYXKEYS.USER,
+ },
+ }),
+)(WorkspaceBillsFirstSection);
diff --git a/src/pages/workspace/bills/WorkspaceBillsNoVBAView.js b/src/pages/workspace/bills/WorkspaceBillsNoVBAView.js
new file mode 100644
index 00000000000..bc88c1a0851
--- /dev/null
+++ b/src/pages/workspace/bills/WorkspaceBillsNoVBAView.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import Text from '../../../components/Text';
+import styles from '../../../styles/styles';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import {
+ ArrowRight,
+ Bank,
+} from '../../../components/Icon/Expensicons';
+import {JewelBoxPink} from '../../../components/Icon/Illustrations';
+import WorkspaceSection from '../WorkspaceSection';
+import Navigation from '../../../libs/Navigation/Navigation';
+import ROUTES from '../../../ROUTES';
+import WorkspaceBillsFirstSection from './WorkspaceBillsFirstSection';
+
+const propTypes = {
+ /** The policy ID currently being configured */
+ policyID: PropTypes.string.isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceBillsNoVBAView = ({translate, policyID}) => (
+ <>
+
+
+ Navigation.navigate(ROUTES.getWorkspaceBankAccountRoute(policyID)),
+ icon: Bank,
+ shouldShowRightIcon: true,
+ iconRight: ArrowRight,
+ },
+ ]}
+ >
+
+ {translate('workspace.bills.unlockNoVBACopy')}
+
+
+ >
+);
+
+WorkspaceBillsNoVBAView.propTypes = propTypes;
+WorkspaceBillsNoVBAView.displayName = 'WorkspaceBillsNoVBAView';
+
+export default withLocalize(WorkspaceBillsNoVBAView);
diff --git a/src/pages/workspace/bills/WorkspaceBillsPage.js b/src/pages/workspace/bills/WorkspaceBillsPage.js
new file mode 100644
index 00000000000..41ebf22b920
--- /dev/null
+++ b/src/pages/workspace/bills/WorkspaceBillsPage.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import WorkspaceBillsNoVBAView from './WorkspaceBillsNoVBAView';
+import WorkspaceBillsVBAView from './WorkspaceBillsVBAView';
+import WorkspacePageWithSections from '../WorkspacePageWithSections';
+
+const propTypes = {
+ /** The route object passed to this page from the navigator */
+ route: PropTypes.shape({
+ /** Each parameter passed via the URL */
+ params: PropTypes.shape({
+ /** The policyID that is being configured */
+ policyID: PropTypes.string.isRequired,
+ }).isRequired,
+ }).isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceBillsPage = ({translate, route}) => (
+
+ {(hasVBA, policyID) => (
+ <>
+ {!hasVBA ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+);
+
+WorkspaceBillsPage.propTypes = propTypes;
+
+export default withLocalize(WorkspaceBillsPage);
diff --git a/src/pages/workspace/bills/WorkspaceBillsVBAView.js b/src/pages/workspace/bills/WorkspaceBillsVBAView.js
new file mode 100644
index 00000000000..243aa362cbc
--- /dev/null
+++ b/src/pages/workspace/bills/WorkspaceBillsVBAView.js
@@ -0,0 +1,50 @@
+import React from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import Text from '../../../components/Text';
+import styles from '../../../styles/styles';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import {
+ Bill,
+ NewWindow,
+} from '../../../components/Icon/Expensicons';
+import {MoneyMousePink} from '../../../components/Icon/Illustrations';
+import WorkspaceSection from '../WorkspaceSection';
+import {openOldDotLink} from '../../../libs/actions/Link';
+import WorkspaceBillsFirstSection from './WorkspaceBillsFirstSection';
+
+const propTypes = {
+ /** The policy ID currently being configured */
+ policyID: PropTypes.string.isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceBillsVBAView = ({translate, policyID}) => (
+ <>
+
+
+ openOldDotLink(`reports?policyID=${policyID}&from=all&type=bill&showStates=Processing,Approved&isAdvancedFilterMode=true`),
+ icon: Bill,
+ shouldShowRightIcon: true,
+ iconRight: NewWindow,
+ },
+ ]}
+ >
+
+ {translate('workspace.bills.VBACopy')}
+
+
+ >
+);
+
+WorkspaceBillsVBAView.propTypes = propTypes;
+WorkspaceBillsVBAView.displayName = 'WorkspaceBillsVBAView';
+
+export default withLocalize(WorkspaceBillsVBAView);
diff --git a/src/pages/workspace/card/WorkspaceCardNoVBAView.js b/src/pages/workspace/card/WorkspaceCardNoVBAView.js
new file mode 100644
index 00000000000..72ad96ca135
--- /dev/null
+++ b/src/pages/workspace/card/WorkspaceCardNoVBAView.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import Text from '../../../components/Text';
+import styles from '../../../styles/styles';
+import Navigation from '../../../libs/Navigation/Navigation';
+import ROUTES from '../../../ROUTES';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import {Bank} from '../../../components/Icon/Expensicons';
+import {JewelBoxBlue} from '../../../components/Icon/Illustrations';
+import UnorderedList from '../../../components/UnorderedList';
+import WorkspaceSection from '../WorkspaceSection';
+
+const propTypes = {
+ /** The policy ID currently being configured */
+ policyID: PropTypes.string.isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceCardNoVBAView = ({translate, policyID}) => (
+ Navigation.navigate(ROUTES.getWorkspaceBankAccountRoute(policyID)),
+ icon: Bank,
+ shouldShowRightIcon: true,
+ },
+ ]}
+ >
+
+ {translate('workspace.card.noVBACopy')}
+
+
+
+
+);
+
+WorkspaceCardNoVBAView.propTypes = propTypes;
+WorkspaceCardNoVBAView.displayName = 'WorkspaceCardNoVBAView';
+
+export default withLocalize(WorkspaceCardNoVBAView);
diff --git a/src/pages/workspace/card/WorkspaceCardPage.js b/src/pages/workspace/card/WorkspaceCardPage.js
new file mode 100644
index 00000000000..8b97933330b
--- /dev/null
+++ b/src/pages/workspace/card/WorkspaceCardPage.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import WorkspaceCardNoVBAView from './WorkspaceCardNoVBAView';
+import WorkspaceCardVBANoECardView from './WorkspaceCardVBANoECardView';
+import WorkspaceCardVBAWithECardView from './WorkspaceCardVBAWithECardView';
+import WorkspacePageWithSections from '../WorkspacePageWithSections';
+
+const propTypes = {
+ /** The route object passed to this page from the navigator */
+ route: PropTypes.shape({
+ /** Each parameter passed via the URL */
+ params: PropTypes.shape({
+ /** The policyID that is being configured */
+ policyID: PropTypes.string.isRequired,
+ }).isRequired,
+ }).isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceCardPage = ({translate, route}) => (
+
+ {(hasVBA, policyID, isUsingECard) => (
+ <>
+ {!hasVBA && (
+
+ )}
+
+ {hasVBA && !isUsingECard && (
+
+ )}
+
+ {hasVBA && isUsingECard && (
+
+ )}
+ >
+ )}
+
+);
+
+WorkspaceCardPage.propTypes = propTypes;
+WorkspaceCardPage.displayName = 'WorkspaceCardPage';
+
+export default withLocalize(WorkspaceCardPage);
diff --git a/src/pages/workspace/card/WorkspaceCardVBANoECardView.js b/src/pages/workspace/card/WorkspaceCardVBANoECardView.js
new file mode 100644
index 00000000000..251a546d6b7
--- /dev/null
+++ b/src/pages/workspace/card/WorkspaceCardVBANoECardView.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import {View} from 'react-native';
+import Text from '../../../components/Text';
+import styles from '../../../styles/styles';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import {Concierge} from '../../../components/Icon/Expensicons';
+import {JewelBoxBlue} from '../../../components/Icon/Illustrations';
+import UnorderedList from '../../../components/UnorderedList';
+import WorkspaceSection from '../WorkspaceSection';
+import {navigateToConciergeChat} from '../../../libs/actions/Report';
+
+const propTypes = {
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceCardVBANoECardView = ({translate}) => (
+
+
+ {translate('workspace.card.VBANoECardCopy')}
+
+
+
+
+
+ {translate('workspace.card.conciergeCanHelp')}
+
+
+);
+
+WorkspaceCardVBANoECardView.propTypes = propTypes;
+WorkspaceCardVBANoECardView.displayName = 'WorkspaceCardVBANoECardView';
+
+export default withLocalize(WorkspaceCardVBANoECardView);
diff --git a/src/pages/workspace/card/WorkspaceCardVBAWithECardView.js b/src/pages/workspace/card/WorkspaceCardVBAWithECardView.js
new file mode 100644
index 00000000000..84f6e604e2b
--- /dev/null
+++ b/src/pages/workspace/card/WorkspaceCardVBAWithECardView.js
@@ -0,0 +1,59 @@
+import React from 'react';
+import {View} from 'react-native';
+import Text from '../../../components/Text';
+import styles from '../../../styles/styles';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import {
+ NewWindow,
+ ExpensifyCard,
+ ReceiptSearch,
+} from '../../../components/Icon/Expensicons';
+import {CreditCardsBlue} from '../../../components/Icon/Illustrations';
+import UnorderedList from '../../../components/UnorderedList';
+import {openOldDotLink} from '../../../libs/actions/Link';
+import WorkspaceSection from '../WorkspaceSection';
+
+const propTypes = {
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceCardVBAWithECardView = ({translate}) => (
+ openOldDotLink('domain_companycards'),
+ icon: ExpensifyCard,
+ shouldShowRightIcon: true,
+ iconRight: NewWindow,
+ },
+ {
+ title: translate('workspace.common.reconcileCards'),
+ onPress: () => openOldDotLink('domain_companycards?param={"section":"cardReconciliation"}'),
+ icon: ReceiptSearch,
+ shouldShowRightIcon: true,
+ iconRight: NewWindow,
+ },
+ ]}
+ >
+
+ {translate('workspace.card.VBAWithECardCopy')}
+
+
+
+
+);
+
+WorkspaceCardVBAWithECardView.propTypes = propTypes;
+WorkspaceCardVBAWithECardView.displayName = 'WorkspaceCardVBAWithECardView';
+
+export default withLocalize(WorkspaceCardVBAWithECardView);
diff --git a/src/pages/workspace/invoices/WorkspaceInvoicesFirstSection.js b/src/pages/workspace/invoices/WorkspaceInvoicesFirstSection.js
new file mode 100644
index 00000000000..c37cd258da8
--- /dev/null
+++ b/src/pages/workspace/invoices/WorkspaceInvoicesFirstSection.js
@@ -0,0 +1,55 @@
+import React from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import Text from '../../../components/Text';
+import styles from '../../../styles/styles';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import {
+ Invoice,
+ NewWindow,
+ Send,
+} from '../../../components/Icon/Expensicons';
+import {MoneyEnvelopeBlue} from '../../../components/Icon/Illustrations';
+import WorkspaceSection from '../WorkspaceSection';
+import {openOldDotLink} from '../../../libs/actions/Link';
+
+const propTypes = {
+ /** The policy ID currently being configured */
+ policyID: PropTypes.string.isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceInvoicesFirstSection = ({translate, policyID}) => (
+ openOldDotLink('reports?param={"createInvoice":true}'),
+ icon: Send,
+ shouldShowRightIcon: true,
+ iconRight: NewWindow,
+ },
+ {
+ title: translate('workspace.invoices.viewAllInvoices'),
+ onPress: () => openOldDotLink(`reports?policyID=${policyID}&from=all&type=invoice&showStates=Open,Processing,Approved,Reimbursed,Archived&isAdvancedFilterMode=true`),
+ icon: Invoice,
+ shouldShowRightIcon: true,
+ iconRight: NewWindow,
+ },
+ ]}
+ >
+
+
+ {translate('workspace.invoices.invoiceFirstSectionCopy')}
+
+
+
+);
+
+WorkspaceInvoicesFirstSection.propTypes = propTypes;
+WorkspaceInvoicesFirstSection.displayName = 'WorkspaceInvoicesFirstSection';
+
+export default withLocalize(WorkspaceInvoicesFirstSection);
diff --git a/src/pages/workspace/invoices/WorkspaceInvoicesNoVBAView.js b/src/pages/workspace/invoices/WorkspaceInvoicesNoVBAView.js
new file mode 100644
index 00000000000..8db86ce7160
--- /dev/null
+++ b/src/pages/workspace/invoices/WorkspaceInvoicesNoVBAView.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import Text from '../../../components/Text';
+import styles from '../../../styles/styles';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import {
+ ArrowRight,
+ Bank,
+} from '../../../components/Icon/Expensicons';
+import {JewelBoxGreen} from '../../../components/Icon/Illustrations';
+import WorkspaceSection from '../WorkspaceSection';
+import Navigation from '../../../libs/Navigation/Navigation';
+import ROUTES from '../../../ROUTES';
+import WorkspaceInvoicesFirstSection from './WorkspaceInvoicesFirstSection';
+
+const propTypes = {
+ /** The policy ID currently being configured */
+ policyID: PropTypes.string.isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceInvoicesNoVBAView = ({translate, policyID}) => (
+ <>
+
+
+ Navigation.navigate(ROUTES.getWorkspaceBankAccountRoute(policyID)),
+ icon: Bank,
+ shouldShowRightIcon: true,
+ iconRight: ArrowRight,
+ },
+ ]}
+ >
+
+ {translate('workspace.invoices.unlockNoVBACopy')}
+
+
+ >
+);
+
+WorkspaceInvoicesNoVBAView.propTypes = propTypes;
+WorkspaceInvoicesNoVBAView.displayName = 'WorkspaceInvoicesNoVBAView';
+
+export default withLocalize(WorkspaceInvoicesNoVBAView);
diff --git a/src/pages/workspace/invoices/WorkspaceInvoicesPage.js b/src/pages/workspace/invoices/WorkspaceInvoicesPage.js
new file mode 100644
index 00000000000..47a4fea43b3
--- /dev/null
+++ b/src/pages/workspace/invoices/WorkspaceInvoicesPage.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import WorkspacePageWithSections from '../WorkspacePageWithSections';
+import WorkspaceInvoicesNoVBAView from './WorkspaceInvoicesNoVBAView';
+import WorkspaceInvoicesVBAView from './WorkspaceInvoicesVBAView';
+
+const propTypes = {
+ /** The route object passed to this page from the navigator */
+ route: PropTypes.shape({
+ /** Each parameter passed via the URL */
+ params: PropTypes.shape({
+ /** The policyID that is being configured */
+ policyID: PropTypes.string.isRequired,
+ }).isRequired,
+ }).isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceInvoicesPage = ({translate, route}) => (
+
+ {(hasVBA, policyID) => (
+ <>
+ {!hasVBA ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+);
+
+WorkspaceInvoicesPage.propTypes = propTypes;
+WorkspaceInvoicesPage.displayName = 'WorkspaceInvoicesPage';
+
+export default withLocalize(WorkspaceInvoicesPage);
diff --git a/src/pages/workspace/invoices/WorkspaceInvoicesVBAView.js b/src/pages/workspace/invoices/WorkspaceInvoicesVBAView.js
new file mode 100644
index 00000000000..87674bed4c2
--- /dev/null
+++ b/src/pages/workspace/invoices/WorkspaceInvoicesVBAView.js
@@ -0,0 +1,50 @@
+import React from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import Text from '../../../components/Text';
+import styles from '../../../styles/styles';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import {
+ CircleHourglass,
+ NewWindow,
+} from '../../../components/Icon/Expensicons';
+import {MoneyMousePink} from '../../../components/Icon/Illustrations';
+import WorkspaceSection from '../WorkspaceSection';
+import WorkspaceInvoicesFirstSection from './WorkspaceInvoicesFirstSection';
+import {openOldDotLink} from '../../../libs/actions/Link';
+
+const propTypes = {
+ /** The policy ID currently being configured */
+ policyID: PropTypes.string.isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceInvoicesVBAView = ({translate, policyID}) => (
+ <>
+
+
+ openOldDotLink(`reports?policyID=${policyID}&from=all&type=invoice&showStates=Processing&isAdvancedFilterMode=true`),
+ icon: CircleHourglass,
+ shouldShowRightIcon: true,
+ iconRight: NewWindow,
+ },
+ ]}
+ >
+
+ {translate('workspace.invoices.unlockVBACopy')}
+
+
+ >
+);
+
+WorkspaceInvoicesVBAView.propTypes = propTypes;
+WorkspaceInvoicesVBAView.displayName = 'WorkspaceInvoicesVBAView';
+
+export default withLocalize(WorkspaceInvoicesVBAView);
diff --git a/src/pages/workspace/reimburse/WorkspaceReimburseNoVBAView.js b/src/pages/workspace/reimburse/WorkspaceReimburseNoVBAView.js
new file mode 100644
index 00000000000..19d65bf5f46
--- /dev/null
+++ b/src/pages/workspace/reimburse/WorkspaceReimburseNoVBAView.js
@@ -0,0 +1,75 @@
+import React from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import Text from '../../../components/Text';
+import styles from '../../../styles/styles';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import {
+ NewWindow,
+ Bank,
+ Receipt,
+} from '../../../components/Icon/Expensicons';
+import {ReceiptYellow, JewelBoxGreen} from '../../../components/Icon/Illustrations';
+import WorkspaceSection from '../WorkspaceSection';
+import Navigation from '../../../libs/Navigation/Navigation';
+import ROUTES from '../../../ROUTES';
+import CopyTextToClipboard from '../../../components/CopyTextToClipboard';
+import {openOldDotLink} from '../../../libs/actions/Link';
+
+const propTypes = {
+ /** The policy ID currently being configured */
+ policyID: PropTypes.string.isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceReimburseNoVBAView = ({translate, policyID}) => (
+ <>
+ openOldDotLink(`expenses?policyIDList=${policyID}&billableReimbursable=reimbursable&submitterEmail=%2B%2B`),
+ icon: Receipt,
+ shouldShowRightIcon: true,
+ iconRight: NewWindow,
+ },
+ ]}
+ >
+
+
+ {translate('workspace.reimburse.captureNoVBACopyBeforeEmail')}
+
+ {translate('workspace.reimburse.captureNoVBACopyAfterEmail')}
+
+
+
+
+ Navigation.navigate(ROUTES.getWorkspaceBankAccountRoute(policyID)),
+ icon: Bank,
+ shouldShowRightIcon: true,
+ },
+ ]}
+ >
+
+ {translate('workspace.reimburse.unlockNoVBACopy')}
+
+
+ >
+);
+
+WorkspaceReimburseNoVBAView.propTypes = propTypes;
+WorkspaceReimburseNoVBAView.displayName = 'WorkspaceReimburseNoVBAView';
+
+export default withLocalize(WorkspaceReimburseNoVBAView);
diff --git a/src/pages/workspace/reimburse/WorkspaceReimbursePage.js b/src/pages/workspace/reimburse/WorkspaceReimbursePage.js
new file mode 100644
index 00000000000..ed2f79d1f76
--- /dev/null
+++ b/src/pages/workspace/reimburse/WorkspaceReimbursePage.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import WorkspaceReimburseNoVBAView from './WorkspaceReimburseNoVBAView';
+import WorkspaceReimburseVBAView from './WorkspaceReimburseVBAView';
+import WorkspacePageWithSections from '../WorkspacePageWithSections';
+
+const propTypes = {
+ /** The route object passed to this page from the navigator */
+ route: PropTypes.shape({
+ /** Each parameter passed via the URL */
+ params: PropTypes.shape({
+ /** The policyID that is being configured */
+ policyID: PropTypes.string.isRequired,
+ }).isRequired,
+ }).isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceReimbursePage = ({translate, route}) => (
+
+ {(hasVBA, policyID) => (
+ <>
+ {!hasVBA ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+);
+
+WorkspaceReimbursePage.propTypes = propTypes;
+WorkspaceReimbursePage.displayName = 'WorkspaceReimbursePage';
+
+export default withLocalize(WorkspaceReimbursePage);
diff --git a/src/pages/workspace/reimburse/WorkspaceReimburseVBAView.js b/src/pages/workspace/reimburse/WorkspaceReimburseVBAView.js
new file mode 100644
index 00000000000..72f88e13239
--- /dev/null
+++ b/src/pages/workspace/reimburse/WorkspaceReimburseVBAView.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import Text from '../../../components/Text';
+import styles from '../../../styles/styles';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import {
+ Bank,
+ Receipt,
+ NewWindow,
+} from '../../../components/Icon/Expensicons';
+import {BankUserGreen, ReceiptYellow} from '../../../components/Icon/Illustrations';
+import WorkspaceSection from '../WorkspaceSection';
+import CopyTextToClipboard from '../../../components/CopyTextToClipboard';
+import {openOldDotLink} from '../../../libs/actions/Link';
+
+const propTypes = {
+ /** The policy ID currently being configured */
+ policyID: PropTypes.string.isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceReimburseVBAView = ({translate, policyID}) => (
+ <>
+ openOldDotLink(`expenses?policyIDList=${policyID}&billableReimbursable=reimbursable&submitterEmail=%2B%2B`),
+ icon: Receipt,
+ shouldShowRightIcon: true,
+ iconRight: NewWindow,
+ },
+ ]}
+ >
+
+
+ {translate('workspace.reimburse.captureNoVBACopyBeforeEmail')}
+
+ {translate('workspace.reimburse.captureNoVBACopyAfterEmail')}
+
+
+
+
+ openOldDotLink(`reports?policyID=${policyID}&from=all&type=expense&showStates=Archived&isAdvancedFilterMode=true`),
+ icon: Bank,
+ shouldShowRightIcon: true,
+ iconRight: NewWindow,
+ },
+ ]}
+ >
+
+ {translate('workspace.reimburse.fastReimbursementsVBACopy')}
+
+
+ >
+);
+
+WorkspaceReimburseVBAView.propTypes = propTypes;
+WorkspaceReimburseVBAView.displayName = 'WorkspaceReimburseVBAView';
+
+export default withLocalize(WorkspaceReimburseVBAView);
diff --git a/src/pages/workspace/travel/WorkspaceTravelNoVBAView.js b/src/pages/workspace/travel/WorkspaceTravelNoVBAView.js
new file mode 100644
index 00000000000..9a251fd79cc
--- /dev/null
+++ b/src/pages/workspace/travel/WorkspaceTravelNoVBAView.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import Text from '../../../components/Text';
+import styles from '../../../styles/styles';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import {
+ ArrowRight,
+ Bank,
+} from '../../../components/Icon/Expensicons';
+import {JewelBoxYellow} from '../../../components/Icon/Illustrations';
+import WorkspaceSection from '../WorkspaceSection';
+import Navigation from '../../../libs/Navigation/Navigation';
+import ROUTES from '../../../ROUTES';
+
+const propTypes = {
+ /** The policy ID currently being configured */
+ policyID: PropTypes.string.isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceTravelNoVBAView = ({translate, policyID}) => (
+ <>
+ Navigation.navigate(ROUTES.getWorkspaceBankAccountRoute(policyID)),
+ icon: Bank,
+ shouldShowRightIcon: true,
+ iconRight: ArrowRight,
+ },
+ ]}
+ >
+
+ {translate('workspace.travel.noVBACopy')}
+
+
+ >
+);
+
+WorkspaceTravelNoVBAView.propTypes = propTypes;
+WorkspaceTravelNoVBAView.displayName = 'WorkspaceTravelNoVBAView';
+
+export default withLocalize(WorkspaceTravelNoVBAView);
diff --git a/src/pages/workspace/travel/WorkspaceTravelPage.js b/src/pages/workspace/travel/WorkspaceTravelPage.js
new file mode 100644
index 00000000000..3e23c251f4f
--- /dev/null
+++ b/src/pages/workspace/travel/WorkspaceTravelPage.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import WorkspacePageWithSections from '../WorkspacePageWithSections';
+import WorkspaceTravelNoVBAView from './WorkspaceTravelNoVBAView';
+import WorkspaceTravelVBAView from './WorkspaceTravelVBAView';
+
+const propTypes = {
+ /** The route object passed to this page from the navigator */
+ route: PropTypes.shape({
+ /** Each parameter passed via the URL */
+ params: PropTypes.shape({
+ /** The policyID that is being configured */
+ policyID: PropTypes.string.isRequired,
+ }).isRequired,
+ }).isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceTravelPage = ({translate, route}) => (
+
+ {(hasVBA, policyID) => (
+ <>
+ {!hasVBA ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+);
+
+WorkspaceTravelPage.propTypes = propTypes;
+WorkspaceTravelPage.displayName = 'WorkspaceTravelPage';
+
+export default withLocalize(WorkspaceTravelPage);
diff --git a/src/pages/workspace/travel/WorkspaceTravelVBAView.js b/src/pages/workspace/travel/WorkspaceTravelVBAView.js
new file mode 100644
index 00000000000..0d1ea8bef7d
--- /dev/null
+++ b/src/pages/workspace/travel/WorkspaceTravelVBAView.js
@@ -0,0 +1,61 @@
+import React from 'react';
+import {View} from 'react-native';
+import Text from '../../../components/Text';
+import styles from '../../../styles/styles';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import {
+ Concierge,
+ ExpensifyCard,
+ NewWindow,
+ Info,
+} from '../../../components/Icon/Expensicons';
+import {RocketOrange} from '../../../components/Icon/Illustrations';
+import WorkspaceSection from '../WorkspaceSection';
+import {openExternalLink, openOldDotLink} from '../../../libs/actions/Link';
+import {navigateToConciergeChat} from '../../../libs/actions/Report';
+import Navigation from '../../../libs/Navigation/Navigation';
+
+const propTypes = {
+ ...withLocalizePropTypes,
+};
+
+const WorkspaceTravelVBAView = ({translate}) => (
+ openOldDotLink('domain_companycards'),
+ icon: ExpensifyCard,
+ shouldShowRightIcon: true,
+ iconRight: NewWindow,
+ },
+ {
+ title: translate('workspace.travel.bookTravelWithConcierge'),
+ onPress: () => {
+ Navigation.dismissModal();
+ navigateToConciergeChat();
+ },
+ icon: Concierge,
+ shouldShowRightIcon: true,
+ },
+ {
+ title: translate('requestorStep.learnMore'),
+ onPress: () => openExternalLink('https://community.expensify.com/discussion/7066/introducing-concierge-travel'),
+ icon: Info,
+ shouldShowRightIcon: true,
+ iconRight: NewWindow,
+ },
+ ]}
+ >
+
+ {translate('workspace.travel.VBACopy')}
+
+
+);
+
+WorkspaceTravelVBAView.propTypes = propTypes;
+WorkspaceTravelVBAView.displayName = 'WorkspaceTravelVBAView';
+
+export default withLocalize(WorkspaceTravelVBAView);
diff --git a/src/stories/Datepicker.stories.js b/src/stories/Datepicker.stories.js
new file mode 100644
index 00000000000..c8dff1b0ee3
--- /dev/null
+++ b/src/stories/Datepicker.stories.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import DatePicker from '../components/DatePicker';
+
+/**
+ * We use the Component Story Format for writing stories. Follow the docs here:
+ *
+ * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format
+ */
+export default {
+ title: 'Components/Datepicker',
+ component: DatePicker,
+ argTypes: {
+ onChange: {action: 'date changed'},
+ },
+ args: {
+ value: '',
+ label: 'Select Date',
+ placeholder: 'Date Placeholder',
+ errorText: '',
+ hasError: false,
+ },
+};
+
+// eslint-disable-next-line react/jsx-props-no-spreading
+const Template = args => ;
+
+// Arguments can be passed to the component by binding
+// See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
+export const Default = Template.bind({});
+export const PreFilled = Template.bind({});
+Default.args = {
+ label: 'Select Date',
+};
+
+PreFilled.args = {
+ label: 'Select Date',
+ value: new Date(2018, 7, 21),
+};
diff --git a/src/styles/stylePropTypes.js b/src/styles/stylePropTypes.js
new file mode 100644
index 00000000000..4c7e825a884
--- /dev/null
+++ b/src/styles/stylePropTypes.js
@@ -0,0 +1,6 @@
+import PropTypes from 'prop-types';
+
+export default PropTypes.oneOfType([
+ PropTypes.object,
+ PropTypes.arrayOf(PropTypes.object),
+]);
diff --git a/src/styles/styles.js b/src/styles/styles.js
index 718e5f4e3aa..c07b9a05916 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -22,10 +22,13 @@ const expensiPicker = {
color: themeColors.text,
fontFamily: fontFamily.GTA,
fontSize: variables.fontSizeNormal,
- paddingHorizontal: 12,
+ paddingHorizontal: 11.5,
paddingBottom: 8,
paddingTop: 24,
height: 52,
+ borderWidth: 1,
+ borderStyle: 'solid',
+ borderColor: themeColors.border,
borderRadius: variables.componentBorderRadiusNormal,
};
@@ -50,6 +53,16 @@ const styles = {
color: themeColors.linkHover,
},
+ linkMuted: {
+ color: themeColors.textSupporting,
+ textDecorationColor: themeColors.textSupporting,
+ fontFamily: fontFamily.GTA,
+ },
+
+ linkMutedHovered: {
+ color: themeColors.textMutedReversed,
+ },
+
h1: {
color: themeColors.heading,
fontFamily: fontFamily.GTA_BOLD,
@@ -153,6 +166,10 @@ const styles = {
color: colors.white,
},
+ textBlue: {
+ color: colors.blue,
+ },
+
textUppercase: {
textTransform: 'uppercase',
},
@@ -219,6 +236,10 @@ const styles = {
fontSize: variables.fontSizeNormal,
fontWeight: fontWeightBold,
textAlign: 'center',
+
+ // It is needed to unset the Lineheight. We don't need it for buttons as button always contains single line of text.
+ // It allows to vertically center the text.
+ lineHeight: undefined,
},
buttonSmall: {
@@ -242,15 +263,13 @@ const styles = {
buttonSmallText: {
fontSize: variables.fontSizeSmall,
- lineHeight: 16,
fontFamily: fontFamily.GTA_BOLD,
fontWeight: fontWeightBold,
textAlign: 'center',
},
buttonLargeText: {
- fontSize: variables.fontSizeLarge,
- lineHeight: 18,
+ fontSize: variables.fontSizeNormal,
fontFamily: fontFamily.GTA_BOLD,
fontWeight: fontWeightBold,
textAlign: 'center',
@@ -351,7 +370,9 @@ const styles = {
paddingTop: 6,
paddingBottom: 6,
borderRadius: variables.componentBorderRadius,
- borderWidth: 0,
+ borderWidth: 1,
+ borderColor: themeColors.border,
+ borderStyle: 'solid',
color: themeColors.text,
height: variables.componentSizeSmall,
opacity: 1,
@@ -364,7 +385,9 @@ const styles = {
paddingRight: 25,
paddingTop: 6,
paddingBottom: 6,
- borderWidth: 0,
+ borderWidth: 1,
+ borderColor: themeColors.border,
+ borderStyle: 'solid',
borderRadius: variables.componentBorderRadius,
color: themeColors.text,
appearance: 'none',
@@ -380,7 +403,9 @@ const styles = {
paddingRight: 25,
paddingTop: 6,
paddingBottom: 6,
- borderWidth: 0,
+ borderWidth: 1,
+ borderColor: themeColors.border,
+ borderStyle: 'solid',
borderRadius: variables.componentBorderRadius,
color: themeColors.text,
height: variables.componentSizeSmall,
@@ -493,16 +518,13 @@ const styles = {
borderWidth: 1,
borderRadius: variables.componentBorderRadiusNormal,
borderColor: themeColors.border,
- paddingTop: 25,
- paddingBottom: 8,
- paddingHorizontal: 12,
justifyContent: 'center',
height: '100%',
backgroundColor: themeColors.componentBG,
},
expensiTextInputLabel: {
position: 'absolute',
- left: 12,
+ left: 11.5,
top: 16,
fontSize: variables.fontSizeNormal,
color: themeColors.textSupporting,
@@ -523,7 +545,11 @@ const styles = {
fontFamily: fontFamily.GTA,
fontSize: variables.fontSizeNormal,
color: themeColors.text,
- ...spacing.pv0,
+ height: '100%',
+ paddingTop: 25,
+ paddingBottom: 8,
+ paddingHorizontal: 11.5,
+ borderRadius: variables.componentBorderRadiusNormal,
},
expensiTextInputDesktop: addOutlineWidth({}, 0),
expensiTextInputAndroid: left => ({
@@ -547,15 +573,14 @@ const styles = {
},
expensiPickerContainer: {
- borderWidth: 1,
+ borderWidth: 0,
borderRadius: variables.componentBorderRadiusNormal,
- borderColor: themeColors.border,
justifyContent: 'center',
backgroundColor: themeColors.componentBG,
},
expensiPickerLabel: {
position: 'absolute',
- left: 12,
+ left: 11.5,
top: 8,
},
expensiPicker: (disabled = false) => ({
@@ -566,7 +591,6 @@ const styles = {
inputWeb: {
appearance: 'none',
cursor: disabled ? 'not-allowed' : 'pointer',
- border: 'none',
...expensiPicker,
},
inputNative: {
@@ -1187,11 +1211,6 @@ const styles = {
borderRadius: 8,
},
- emojiSkinToneItem: {
- width: 'auto',
- ...spacing.ph1,
- },
-
emojiItemHighlighted: {
transition: '0.2s ease',
backgroundColor: themeColors.buttonDefaultBG,
@@ -1375,6 +1394,25 @@ const styles = {
flex: 1,
},
+ borderTop: {
+ borderTopWidth: 1,
+ borderColor: themeColors.border,
+ },
+
+ borderTopRounded: {
+ borderTopWidth: 1,
+ borderColor: themeColors.border,
+ borderTopLeftRadius: variables.componentBorderRadiusNormal,
+ borderTopRightRadius: variables.componentBorderRadiusNormal,
+ },
+
+ borderBottomRounded: {
+ borderBottomWidth: 1,
+ borderColor: themeColors.border,
+ borderBottomLeftRadius: variables.componentBorderRadiusNormal,
+ borderBottomRightRadius: variables.componentBorderRadiusNormal,
+ },
+
borderBottom: {
borderBottomWidth: 1,
borderColor: themeColors.border,
@@ -1385,6 +1423,11 @@ const styles = {
borderColor: themeColors.border,
},
+ borderLeft: {
+ borderLeftWidth: 1,
+ borderColor: themeColors.border,
+ },
+
headerBar: {
overflow: 'hidden',
justifyContent: 'center',
@@ -1770,7 +1813,7 @@ const styles = {
zIndex: 10,
},
- vbaFullScreenLoading: {
+ reimbursementAccountFullScreenLoading: {
backgroundColor: themeColors.componentBG,
opacity: 0.8,
justifyContent: 'flex-start',
@@ -1837,6 +1880,10 @@ const styles = {
cursor: 'not-allowed',
},
+ cursorPointer: {
+ cursor: 'pointer',
+ },
+
fullscreenCard: {
position: 'absolute',
left: 0,
@@ -2025,6 +2072,29 @@ const styles = {
{translateY},
],
}),
+
+ confettiIcon: {
+ height: 100,
+ width: 100,
+ marginBottom: 20,
+ },
+
+ googleSearchTextInputContainer: {
+ flexDirection: 'column',
+ },
+
+ googleSearchSeparator: {
+ height: 1,
+ backgroundColor: themeColors.border,
+ },
+
+ googleSearchText: {
+ color: themeColors.text,
+ fontSize: variables.fontSizeNormal,
+ lineHeight: variables.fontSizeNormalHeight,
+ fontFamily: fontFamily.GTA,
+ flex: 1,
+ },
};
const baseCodeTagStyles = {
diff --git a/src/styles/utilities/flex.js b/src/styles/utilities/flex.js
index ebb53919d29..7956d5dab13 100644
--- a/src/styles/utilities/flex.js
+++ b/src/styles/utilities/flex.js
@@ -64,6 +64,10 @@ export default {
alignSelf: 'flex-start',
},
+ alignSelfEnd: {
+ alignSelf: 'flex-end',
+ },
+
alignItemsStart: {
alignItems: 'flex-start',
},
diff --git a/src/styles/utilities/spacing.js b/src/styles/utilities/spacing.js
index 318e1c734a4..9ad074e1924 100644
--- a/src/styles/utilities/spacing.js
+++ b/src/styles/utilities/spacing.js
@@ -264,6 +264,10 @@ export default {
paddingLeft: 20,
},
+ pt0: {
+ paddingTop: 0,
+ },
+
pt2: {
paddingTop: 8,
},
diff --git a/src/styles/variables.js b/src/styles/variables.js
index 25b36949e93..e57db6ae87b 100644
--- a/src/styles/variables.js
+++ b/src/styles/variables.js
@@ -19,7 +19,6 @@ export default {
fontSizeLarge: 17,
fontSizeHero: 36,
fontSizeh1: 19,
- fontSizeXLarge: 24,
fontSizeXXXLarge: 32,
fontSizeNormalHeight: 20,
lineHeightHero: 40,
diff --git a/web/index.html b/web/index.html
index 8a343870fe9..58ef693a1a1 100644
--- a/web/index.html
+++ b/web/index.html
@@ -3,14 +3,14 @@
New Expensify
-
+
-
-
+
+
diff --git a/web/og-preview-image.png b/web/og-preview-image.png
index 9f0b1d09a46..75b182a49f8 100644
Binary files a/web/og-preview-image.png and b/web/og-preview-image.png differ