├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ └── feature.md ├── labeler.yml ├── pull_request_template.md ├── semantic.yml └── workflows │ ├── CI.yml │ ├── CreateRelease.yml │ ├── PullRequestLabeler.yml │ └── SendEmail.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __mocks__ ├── @react-native-async-storage │ └── async-storage.js ├── react-native-device-info.js ├── react-native.js └── test-storage.js ├── babel.config.js ├── commitlint.config.js ├── jest.config.js ├── lerna.json ├── package.json ├── packages ├── react-native-analytics │ ├── CHANGELOG.md │ ├── README.md │ ├── __mocks__ │ │ └── react-native-forter.js │ ├── package.json │ └── src │ │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── index.test.js.snap │ │ ├── analytics.test.js │ │ └── index.test.js │ │ ├── analytics.js │ │ ├── contexts │ │ ├── __tests__ │ │ │ ├── deviceLanguageContext.test.js │ │ │ ├── deviceModelContext.test.js │ │ │ ├── deviceOSContext.test.js │ │ │ ├── getClientInstallIdContext.test.js │ │ │ └── screenDimensionsContext.test.js │ │ ├── deviceLanguageContext.js │ │ ├── deviceModelContext.js │ │ ├── deviceOSContext.js │ │ ├── getClientInstallIdContext.js │ │ ├── index.js │ │ └── screenDimensionsContext.js │ │ ├── eventTypes.js │ │ ├── index.js │ │ ├── integrations │ │ ├── __fixtures__ │ │ │ ├── baseAnalyticsEventData.fixtures.js │ │ │ ├── eventSamples.fixtures.js │ │ │ └── generateAnalyticsEventData.fixtures.js │ │ ├── castle │ │ │ ├── Castle.js │ │ │ ├── __tests__ │ │ │ │ └── Castle.test.js │ │ │ └── index.js │ │ ├── firebaseAnalytics │ │ │ ├── FirebaseAnalytics.js │ │ │ ├── __tests__ │ │ │ │ ├── FirebaseAnalytics.test.js │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── FirebaseAnalytics.test.js.snap │ │ │ │ └── utils.test.js │ │ │ ├── constants.js │ │ │ ├── defaultMappers │ │ │ │ ├── defaultEventsMapper.js │ │ │ │ ├── defaultScreenViewsMapper.js │ │ │ │ ├── getMappedEventPropertiesForEvent.js │ │ │ │ ├── getVirtualEventsFromEvent.js │ │ │ │ ├── index.js │ │ │ │ └── virtualEventTypes.js │ │ │ ├── index.js │ │ │ └── utils.js │ │ ├── forter │ │ │ ├── Forter.js │ │ │ ├── __tests__ │ │ │ │ └── Forter.test.js │ │ │ ├── constants.js │ │ │ ├── index.js │ │ │ └── utils │ │ │ │ ├── defaultActionCommandBuilder.js │ │ │ │ └── defaultNavigationCommandBuilder.js │ │ ├── integration │ │ │ └── index.js │ │ ├── omnitracking │ │ │ └── index.js │ │ └── shared │ │ │ └── dataMappings │ │ │ └── index.js │ │ └── screenTypes.js ├── react-native-metro-transformer │ ├── CHANGELOG.md │ ├── README.md │ ├── __mocks__ │ │ ├── metro-transform-worker.js │ │ └── metro │ │ │ └── src │ │ │ └── JSTransformer │ │ │ └── worker.js │ ├── __tests__ │ │ └── index.test.js │ ├── index.js │ ├── package.json │ └── src │ │ ├── transformers │ │ ├── __tests__ │ │ │ ├── legacyTransformer.test.js │ │ │ └── modernTransformer.test.js │ │ ├── legacyTransformer.js │ │ └── modernTransformer.js │ │ └── utils │ │ └── filterPackageJsonFields.js └── react-native-riskified-integration │ ├── CHANGELOG.md │ ├── README.md │ ├── android │ ├── .settings │ │ └── org.eclipse.buildship.core.prefs │ ├── README.md │ ├── build.gradle │ ├── libs │ │ └── riskifiedbeacon-release.aar │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── com │ │ └── farfetch │ │ └── reactnative │ │ ├── RiskifiedIntegrationModule.java │ │ └── RiskifiedIntegrationPackage.java │ ├── ios │ ├── RiskifiedIntegration.h │ ├── RiskifiedIntegration.m │ ├── RiskifiedIntegration.xcodeproj │ │ └── project.pbxproj │ ├── RiskifiedIntegration.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── libs │ │ ├── RiskifiedBeacon.h │ │ └── libriskifiedbeacon.a │ ├── package.json │ ├── react-native-riskified-integration.podspec │ └── src │ ├── Riskified.js │ ├── __tests__ │ └── Riskified.test.js │ └── index.js └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | packages/*/docs 2 | node_modules 3 | /coverage -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: '@react-native-community', 4 | rules: { 5 | 'no-unused-vars': ['error', { ignoreRestSiblings: true }], 6 | quotes: [ 7 | 'error', 8 | 'single', 9 | { avoidEscape: true, allowTemplateLiterals: false }, 10 | ], 11 | }, 12 | overrides: [ 13 | { 14 | files: ['jest/**/*.js'], 15 | env: { 16 | jest: true, 17 | }, 18 | }, 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pbxproj -text 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug" 3 | about: Report a detailed bug or problem. 4 | labels: "type: bug" 5 | --- 6 | 7 | ## Expected behavior 8 | 9 | 12 | 13 | ## Actual behavior 14 | 15 | 18 | 19 | ## Steps to reproduce 20 | 21 | 25 | 26 | 1. 27 | 2. 28 | 3. 29 | 4. 30 | 31 | ## Context/environment 32 | 33 | 37 | 38 | ## Additional information 39 | 40 | 43 | 44 | ## Possible fix 45 | 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature" 3 | about: Request a feature or a different way of doing something. 4 | labels: "type: feature" 5 | --- 6 | 7 | ## Is your proposal related to a problem? 8 | 9 | 13 | 14 | ## Describe the solution you'd like 15 | 16 | 20 | 21 | ## Describe alternatives you've considered 22 | 23 | 26 | 27 | ## Additional information 28 | 29 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | ":package: react-native-analytics": 2 | - 'packages/react-native-analytics/**/*' 3 | - 'packages/react-native-analytics/**/.*' 4 | - 'packages/react-native-analytics/.**/*' 5 | - 'packages/react-native-analytics/.**/.*' 6 | 7 | ":package: react-native-metro-transformer": 8 | - 'packages/react-native-metro-transformer/**/*' 9 | - 'packages/react-native-metro-transformer/**/.*' 10 | - 'packages/react-native-metro-transformer/.**/*' 11 | - 'packages/react-native-metro-transformer/.**/.*' 12 | 13 | ":package: react-native-riskified-integration": 14 | - 'packages/react-native-riskified-integration/**/*' 15 | - 'packages/react-native-riskified-integration/**/.*' 16 | - 'packages/react-native-riskified-integration/.**/*' 17 | - 'packages/react-native-riskified-integration/.**/.*' 18 | 19 | "type: maintenance": 20 | - any: ['.github/**/*', '!**/*.md', '!.**/*.md'] 21 | 22 | "type: documentation": 23 | - '**/*.md' 24 | - '**/.*.md' 25 | - '.**/*.md' 26 | - '.**/.*.md' -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 7 | 8 | 13 | 14 | 20 | 21 | ### Dependencies 22 | 23 | 30 | 31 | ## Checklist 32 | 33 | 37 | 38 | - [ ] The commit message follows our guidelines 39 | - [ ] Tests for the respective changes have been added 40 | - [ ] The code is commented, particularly in hard-to-understand areas 41 | - [ ] The labels and/or milestones were added 42 | 43 | ## Disclaimer 44 | 45 | By sending us your contributions, you are agreeing that your contribution is made subject to the terms of our [Contributor Ownership Statement](https://github.com/Farfetch/.github/blob/master/COS.md) -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | # Always validate all commits, and ignore the PR title 2 | commitsOnly: true 3 | 4 | # Require at least one commit to be valid 5 | # this is only relevant when using commitsOnly: true or titleAndCommits: true, 6 | # which validate all commits by default 7 | anyCommit: false 8 | 9 | # Allow use of Merge commits (eg on github: "Merge branch 'master' into feature/ride-unicorns") 10 | # this is only relevant when using commitsOnly: true (or titleAndCommits: true) 11 | allowMergeCommits: true 12 | 13 | # Allow use of Revert commits (eg on github: "Revert "feat: ride unicorns"") 14 | # this is only relevant when using commitsOnly: true (or titleAndCommits: true) 15 | allowRevertCommits: true -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Controls when the action will run. Triggers the workflow on push or pull request 4 | # events but only for the main and next branches 5 | on: 6 | push: 7 | branches: 8 | - main 9 | - next 10 | paths-ignore: 11 | - '**/docs/**' 12 | - '**.md' 13 | pull_request: 14 | types: [opened, synchronize, reopened, edited, ready_for_review] 15 | paths-ignore: 16 | - '**/docs/**' 17 | - '**.md' 18 | 19 | # Setup concurrency to the ref (branch / tag) that triggered the workflow 20 | concurrency: ci-${{ github.ref }} 21 | 22 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 23 | jobs: 24 | # This workflow contains a single job called "CI" 25 | CI: 26 | # The type of runner that the job will run on 27 | runs-on: ubuntu-latest 28 | # Do not run if the pull request is a draft 29 | if: ${{ !github.event.pull_request.draft }} 30 | 31 | # Steps represent a sequence of tasks that will be executed as part of the job 32 | steps: 33 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 34 | # Use fetch-depth: 0 so that all tags and branches are fetched 35 | # Use persist-credentials: false so that the make release step uses another personal access 36 | # token which has admin access and can push the version commit without the restriction 37 | # of creating a pull-request. 38 | - uses: actions/checkout@v2 39 | with: 40 | fetch-depth: 0 41 | persist-credentials: false 42 | 43 | - uses: actions/setup-node@v2 44 | with: 45 | node-version: '14' 46 | registry-url: 'https://registry.npmjs.org' 47 | scope: '@farfetch' 48 | cache: 'yarn' 49 | always-auth: true 50 | 51 | # This is needed for lerna to commit and push the 52 | # new version when making a release 53 | - name: Checkout the source branch in a pull request for lerna 54 | if: ${{ github.event_name == 'pull_request' && startsWith(github.head_ref, 'rc/') }} 55 | run: | 56 | git checkout "${{ github.head_ref }}" 57 | 58 | # This is needed for lerna to commit and push the 59 | # new version when making a release 60 | - name: Checkout the branch for pushes to a branch for lerna 61 | if: ${{ github.event_name == 'push' }} 62 | run: | 63 | git checkout "${{ github.ref_name }}" 64 | 65 | # Retrieves the commit message to be used in the 66 | # make release step 67 | - name: Get commit message 68 | id: get-commit-message 69 | run: | 70 | COMMIT_MSG=$(git log -1 --pretty=format:"%s") 71 | echo "Commit message is: ${COMMIT_MSG})" 72 | echo ::set-output name=message::${COMMIT_MSG} 73 | 74 | - name: Install dependencies 75 | run: yarn install --ignore-engines --frozen-lockfile 76 | 77 | - name: Lint 78 | run: yarn lint 79 | 80 | - name: Test 81 | run: yarn test --ci 82 | 83 | # Only make a release if it is a run of the 'main' or 'next' branches 84 | # or a pull request that contains a "chore: make release" message 85 | - name: Make release 86 | if: | 87 | github.ref_name == 'main' || 88 | github.ref_name == 'next' || 89 | (github.event_name == 'pull_request' && startsWith(github.head_ref, 'rc/')) 90 | env: 91 | MAKE_RELEASE_COMMIT_MESSAGE: 'chore: make release' 92 | PUBLISH_COMMIT_MESSAGE: 'chore: publish [skip ci]' 93 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 94 | GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} 95 | GIT_AUTHOR_NAME: ${{ secrets.RELEASE_BOT_GIT_NAME }} 96 | GIT_AUTHOR_EMAIL: ${{ secrets.RELEASE_BOT_GIT_EMAIL }} 97 | GIT_COMMITTER_NAME: ${{ secrets.RELEASE_BOT_GIT_NAME }} 98 | GIT_COMMITTER_EMAIL: ${{ secrets.RELEASE_BOT_GIT_EMAIL }} 99 | run: | 100 | git remote set-url origin "https://${GITHUB_TOKEN}@github.com/Farfetch/blackout-react-native.git" 101 | 102 | SOURCE_BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) 103 | 104 | if [[ ${SOURCE_BRANCH_NAME} = main ]]; then 105 | npx lerna publish --conventional-commits --message "${PUBLISH_COMMIT_MESSAGE}" --no-verify-access --yes 106 | else 107 | if [[ ${SOURCE_BRANCH_NAME} = next ]]; then 108 | PRE_ID=next 109 | else 110 | PRE_ID=rc 111 | fi 112 | npx lerna publish --conventional-commits --conventional-prerelease --no-verify-access --preid ${PRE_ID} --pre-dist-tag ${PRE_ID} --message "${PUBLISH_COMMIT_MESSAGE}" --yes 113 | fi 114 | -------------------------------------------------------------------------------- /.github/workflows/CreateRelease.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - '**' 5 | 6 | name: Create Release 7 | 8 | jobs: 9 | create_release: 10 | name: Create Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | - name: Parse semver string 16 | id: semver_parser 17 | uses: booxmedialtd/ws-action-parse-semver@v1 18 | with: 19 | input_string: ${{ github.ref }} 20 | version_extractor_regex: '([0-9]+.[0-9]+.[0-9]+.*)$' # The regexp must contain a capture group containing the version expression, i.e., the parenthesis are needed 21 | - name: Use parsed semver # Print parsed semver data for debugging 22 | run: | 23 | echo "${{ steps.semver_parser.outputs.major }}" 24 | echo "${{ steps.semver_parser.outputs.minor }}" 25 | echo "${{ steps.semver_parser.outputs.patch }}" 26 | echo "${{ steps.semver_parser.outputs.prerelease }}" 27 | echo "${{ steps.semver_parser.outputs.build }}" 28 | echo "${{ steps.semver_parser.outputs.fullversion }}" 29 | - name: Exit early if it is a prerelease # Hack to exit the workflow when it is a prerelease. This is because Github Actions does not allow to abort workflow early 30 | if: steps.semver_parser.outputs.prerelease != '' 31 | run: exit 1 32 | - name: Get package name # Get the package name from the tag so we can find the correct changelog file 33 | id: get_package_name 34 | uses: actions/github-script@v6 35 | with: 36 | result-encoding: string 37 | script: | 38 | const regex = /blackout-(.*)@/g; 39 | return regex.exec(context.ref)[1]; 40 | - name: Test package name # Print obtained package name for debugging only 41 | run: | 42 | echo "package name is: ${{ steps.get_package_name.outputs.result }}" 43 | - name: Extract release notes # This step will retrieve the last entry from the changelog 44 | id: extract_release_notes 45 | uses: ffurrer2/extract-release-notes@v1 46 | with: 47 | changelog_file: "./packages/${{ steps.get_package_name.outputs.result }}/CHANGELOG.md" 48 | - name: Test release notes # Print the extracted release notes for debugging 49 | run: | 50 | echo "release notes are: ${{ steps.extract_release_notes.outputs.release_notes }}" 51 | - name: Create Release # Create release in github 52 | id: create_release 53 | uses: actions/create-release@v1 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} 56 | with: 57 | tag_name: ${{ github.ref }} 58 | release_name: Release ${{ github.ref }} 59 | body: ${{ steps.extract_release_notes.outputs.release_notes }} 60 | draft: false 61 | prerelease: false -------------------------------------------------------------------------------- /.github/workflows/PullRequestLabeler.yml: -------------------------------------------------------------------------------- 1 | name: "Pull Request Labeler" 2 | on: 3 | - pull_request_target 4 | 5 | jobs: 6 | triage: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/labeler@v3 10 | with: 11 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 12 | sync-labels: true 13 | 14 | conventional-commits-labeler: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: bcoe/conventional-release-labels@v1 18 | with: 19 | type_labels: | 20 | { 21 | "breaking": ":rotating_light: BREAKING CHANGE", 22 | "feat": "type: feature", 23 | "fix": "type: bug", 24 | "docs": "type: documentation", 25 | "perf": "type: enhancement", 26 | "refactor": "type: enhancement", 27 | "test": "type: enhancement" 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/SendEmail.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [published] 4 | 5 | name: Send email with release notes 6 | 7 | jobs: 8 | send_email: 9 | name: Send email with release notes 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Send mail 13 | uses: dawidd6/action-send-mail@v3 14 | with: 15 | # Required mail server address: 16 | server_address: ${{ secrets.EMAIL_SERVER_ADDRESS }} 17 | # Required mail server port: 18 | server_port: ${{ secrets.EMAIL_SERVER_PORT }} 19 | # Required username 20 | username: ${{ secrets.EMAIL_ACCOUNT_USERNAME }} 21 | # Required password 22 | password: ${{ secrets.EMAIL_ACCOUNT_PASSWORD }} 23 | # Required mail subject. Will be the release name. 24 | subject: ${{ github.event.release.name }} 25 | # Required recipients' addresses: 26 | to: ${{ secrets.EMAIL_RECIPIENTS }} 27 | # Required sender full name (address can be skipped): 28 | from: ${{ secrets.EMAIL_FROM }} 29 | # Optional whether this connection use TLS (default is true if server_port is 465) 30 | secure: true 31 | # Optional HTML body: Body of the release converted to markdown by use of the option 'convert_markdown'. 32 | html_body: ${{ github.event.release.body }} 33 | # Optional unsigned/invalid certificates allowance: 34 | ignore_cert: true 35 | # Optional converting Markdown to HTML (set content_type to text/html too): 36 | convert_markdown: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | project.xcworkspace 24 | 25 | # Android/IntelliJ 26 | # 27 | build/ 28 | .idea 29 | .gradle 30 | local.properties 31 | *.iml 32 | 33 | # Eclipse 34 | # 35 | .history 36 | 37 | # node.js 38 | # 39 | node_modules/ 40 | package-lock.json 41 | .yarn-integrity 42 | 43 | # BUCK 44 | buck-out/ 45 | \.buckd/ 46 | *.keystore 47 | 48 | # fastlane 49 | # 50 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 51 | # screenshots whenever they are needed. 52 | # For more information about the recommended setup visit: 53 | # https://docs.fastlane.tools/best-practices/source-control/ 54 | 55 | */fastlane/report.xml 56 | */fastlane/Preview.html 57 | */fastlane/screenshots 58 | 59 | # Bundle artifacts 60 | *.jsbundle 61 | 62 | # CocoaPods 63 | /ios/Pods/ 64 | 65 | # Expo 66 | .expo/* 67 | 68 | # Docs 69 | **/docs/** 70 | !/docs/contributing 71 | !/docs/contributing/** 72 | 73 | # Jest 74 | /coverage 75 | 76 | # Logs 77 | *.log 78 | 79 | # Vscode 80 | .vscode/ 81 | 82 | # Eclipse Core 83 | .project 84 | .classpath 85 | 86 | .eslintcache -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "arrowParens": "avoid" 5 | } 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource@farfetch.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 4 | 5 | Please note that this project is released with a [Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. 6 | 7 | - [Contributing](#contributing) 8 | - [Issues](#issues) 9 | - [Pull Requests](#pull-requests) 10 | - [Does it state intent](#does-it-state-intent) 11 | - [Is it of good quality](#is-it-of-good-quality) 12 | - [Workflow](#workflow) 13 | - [Code review and approval process](#code-review-and-approval-process) 14 | - [Release process](#release-process) 15 | - [Your First Contribution](#your-first-contribution) 16 | - [Additional resources](#additional-resources) 17 | - [Disclaimer](#disclaimer) 18 | 19 | ## Issues 20 | 21 | Issues are very valuable to this project. 22 | 23 | - Ideas are a valuable source of contributions others can make 24 | - Problems show where this project is lacking 25 | - With a question, you show where contributors can improve the user experience 26 | 27 | When you create a new issue, you need to choose the respective template and it'll guide you through collecting and providing the information needed. 28 | 29 | If you find an issue that addresses the problem you're having, please add your own reproduction information to the existing issue rather than creating a new one. Adding a [reaction](https://github.blog/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) can also help indicate to our maintainers that a particular problem is affecting more than just the reporter. 30 | 31 | ## Pull Requests 32 | 33 | PRs are always welcome and can be a quick way to get your fix or improvement slated for the next release. 34 | 35 | In general, we follow the ["fork-and-pull" Git workflow](https://github.com/susam/gitpr) 36 | 37 | 1. Fork the repository to your own Github account 38 | 2. Clone the project to your machine 39 | 3. Create a branch locally with a succinct but descriptive name 40 | 4. Commit changes to the branch 41 | 5. Follow any guidelines specific to this repo 42 | 6. Push changes to your fork 43 | 7. Open a PR in our repository and follow the PR template so that we can efficiently review the changes. 44 | 45 | When opening a pull request, consider the following: 46 | 47 | ### Does it state intent 48 | 49 | You should be clear about which problem you're trying to solve with your contribution. For example: 50 | 51 | > Add a link to code of conduct in README.md 52 | This doesn't tell anything about why it's being done, unlike 53 | 54 | > Add a link to code of conduct in README.md because users don't always look in the CONTRIBUTING.md 55 | This tells the problem that you have found, and the pull request shows the action you have taken to solve it. 56 | 57 | The same principle applies to the commit body. 58 | 59 | ### Is it of good quality 60 | 61 | - It follows the provided template 62 | - There are no spelling mistakes 63 | - It follows the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) specification 64 | 65 | ### Code review and approval process 66 | 67 | Our maintainers look at pull requests on a regular basis, and the process follows some simple steps: 68 | 69 | 1. PR gets a minimum of 2 approvals when reviewing 70 | 2. After being reviewed, it is tested by our internal QA team 71 | 1. Applicable to bugfixes and features 72 | 3. After being approved, it is merged 73 | 74 | Note that after feedback has been given we expect responses within two weeks. After two weeks we may close the pull request if it isn't showing any activity. 75 | 76 | ### Release process 77 | 78 | New releases happen automatically with every commit added to the `main` branch. 79 | 80 | ## Your First Contribution 81 | 82 | If you want to deep dive and help out with development, then first get the project installed locally. 83 | After that is done we suggest you have a look at issues that are labelled "[good first issue](https://github.com/Farfetch/blackout-react-native/labels/good%20first%20issue)". 84 | These are meant to be a great way to get a smooth start and won’t put you in front of the most complex parts of the system. 85 | 86 | ## Additional resources 87 | 88 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 89 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 90 | 91 | ## Disclaimer 92 | 93 | By sending us your contributions, you are agreeing that your contribution is made subject to the terms of our [Contributor Ownership Statement](https://github.com/Farfetch/.github/blob/master/COS.md) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 FARFETCH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blackout-react-native 2 | 3 | [![Pipeline](https://github.com/Farfetch/blackout-react-native/actions/workflows/CI.yml/badge.svg)](https://github.com/Farfetch/blackout-react-native/actions/workflows/CI.yml) 4 | [![MIT License](https://img.shields.io/apm/l/atomic-design-ui)](https://github.com/Farfetch/blackout-react-native/blob/main/LICENSE) 5 | [![GitHub last commit](https://img.shields.io/github/last-commit/Farfetch/blackout)](https://github.com/Farfetch/blackout-react-native/graphs/commit-activity) 6 | 7 | Blackout-react-native is the codename for the Farfetch Platform Solutions (FPS) react-native projects. It's a monorepo with Yarn workspaces and Lerna. 8 | 9 | Useful to build e-commerce native apps using the FPS APIs and integrating business logic. 10 | 11 | ## What's inside 12 | 13 | Each package has its own `package.json` file and defines its dependencies, having full autonomy to publish a new version into the registry when needed. 14 | 15 | [**@farfetch/blackout-react-native-analytics**](packages/react-native-analytics) 16 | 17 | - Analytics solution for react-native apps 18 | - Depends on [`@farfetch/blackout-core`](https://www.npmjs.com/package/@farfetch/blackout-core) 19 | 20 | [**@farfetch/blackout-react-native-metro-transformer**](packages/react-native-metro-transformer) 21 | 22 | - Custom transformer for `metro` to be used by FPS react-native apps 23 | 24 | [**@farfetch/blackout-react-native-riskified-integration**](packages/react-native-riskified-integration) 25 | 26 | - Riskified integration for @farfetch/blackout-react-native-analytics 27 | - Depends on 28 | - [`@farfetch/blackout-core`](https://www.npmjs.com/package/@farfetch/blackout-core) 29 | - [`@farfetch/blackout-react-native-analytics`](https://www.npmjs.com/package/@farfetch/blackout-react-native-analytics) 30 | 31 | ## Contributing 32 | 33 | Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change. 34 | 35 | Please read the [CONTRIBUTING](CONTRIBUTING.md) file to know what we expect from your contribution and the guidelines you should follow. 36 | 37 | ## About 38 | 39 | Blackout-react-native is a project maintained by some awesome [contributors](https://github.com/Farfetch/blackout-react-native/graphs/contributors) from [Farfetch Platform Solutions](https://www.farfetchplatformsolutions.com/). 40 | 41 | ## Maintainers 42 | 43 | - [Bruno Oliveira](https://github.com/boliveira) 44 | - [Gabriel Pires](https://github.com/gabrielfmp) 45 | - [Helder Burato Berto](https://github.com/helderburato) 46 | - [Nelson Leite](https://github.com/nelsonleite) 47 | - [Pedro Barreiro](https://github.com/pedro-gbf) 48 | 49 | ## License 50 | 51 | [MIT](LICENSE) @ Farfetch 52 | -------------------------------------------------------------------------------- /__mocks__/@react-native-async-storage/async-storage.js: -------------------------------------------------------------------------------- 1 | export default from '@react-native-async-storage/async-storage/jest/async-storage-mock'; 2 | -------------------------------------------------------------------------------- /__mocks__/react-native-device-info.js: -------------------------------------------------------------------------------- 1 | export const getDeviceId = jest 2 | .fn() 3 | .mockImplementation(() => 'iPhone 11 Pro Max'); 4 | export const getModel = jest.fn().mockImplementation(() => 'iPhone12,5'); 5 | export const getSystemName = jest.fn().mockImplementation(() => 'iOS'); 6 | export const getSystemVersion = jest.fn().mockImplementation(() => '13.0'); 7 | -------------------------------------------------------------------------------- /__mocks__/react-native.js: -------------------------------------------------------------------------------- 1 | export const NativeModules = { 2 | SettingsManager: { 3 | settings: { 4 | AppleLocale: 'en', 5 | AppleLanguages: ['pt-PT', 'en'], 6 | }, 7 | }, 8 | I18nManager: { 9 | localeIdentifier: 'en', 10 | }, 11 | Castle: () => undefined, 12 | }; 13 | 14 | export const Platform = { 15 | OS: 'ios', 16 | }; 17 | 18 | export const Dimensions = { 19 | get: dim => { 20 | if (dim === 'window') { 21 | return { 22 | width: 800, 23 | height: 1000, 24 | }; 25 | } 26 | 27 | if (dim === 'screen') { 28 | return { 29 | width: 800, 30 | height: 1100, 31 | }; 32 | } 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /__mocks__/test-storage.js: -------------------------------------------------------------------------------- 1 | //Dummy storage to be used only in tests that 2 | //need to call analytics.setStorage 3 | export default class TestStorage { 4 | items = {}; 5 | 6 | getItem(key) { 7 | return this.items[key]; 8 | } 9 | 10 | setItem(key, data) { 11 | this.items[key] = data; 12 | } 13 | 14 | removeItem(key) { 15 | delete this.items[key]; 16 | } 17 | 18 | clear() { 19 | this.items = {}; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:metro-react-native-babel-preset'], 3 | }; 4 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Indicates whether the coverage information should be collected while executing the test 3 | collectCoverage: true, 4 | // An array of glob patterns indicating a set of files for which coverage information should be collected 5 | collectCoverageFrom: ['./packages/**/src/**/*.{js,jsx}'], 6 | // An array of regexp pattern strings used to skip coverage collection 7 | coveragePathIgnorePatterns: ['/node_modules/', '__tests__/'], 8 | // An object that configures minimum threshold enforcement for coverage results 9 | coverageThreshold: { 10 | global: { 11 | branches: 95, 12 | functions: 95, 13 | lines: 95, 14 | statements: 95, 15 | }, 16 | }, 17 | // A map from regular expressions to module names that allow to stub out resources with a single module 18 | moduleNameMapper: { 19 | '^@farfetch/blackout-core(.*)$': '@farfetch/blackout-core/src$1', 20 | '^@farfetch/blackout-react-native-analytics(.*)$': 21 | '@farfetch/blackout-react-native-analytics/src$1', 22 | }, 23 | // A preset that is used as a base for Jest's configuration 24 | preset: 'react-native', 25 | // The regexp pattern or array of patterns that Jest uses to detect test files 26 | testRegex: '.+\\.test.js$', 27 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 28 | transformIgnorePatterns: [ 29 | 'node_modules/(?!(react-native|react-navigation|@react-navigation|@react-native-community|@farfetch|@react-native-firebase/*))', 30 | ], 31 | }; 32 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "independent", 3 | "npmClient": "yarn", 4 | "ignoreChanges": [ 5 | "**/__fixtures__/**", 6 | "**/__tests__/**", 7 | "**/*.md", 8 | "**/docs/**" 9 | ], 10 | "useWorkspaces": true 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "@farfetch/blackout-react-native", 4 | "description": "Farfetch Platform Solutions (FPS) react-native projects", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/Farfetch/blackout-react-native.git" 8 | }, 9 | "author": { 10 | "name": "FPS", 11 | "email": "opensource@farfetch.com", 12 | "url": "https://www.farfetchplatformsolutions.com" 13 | }, 14 | "bugs": "https://github.com/Farfetch/blackout-react-native/issues", 15 | "keywords": [ 16 | "blackout-react-native", 17 | "e-commerce", 18 | "Farfetch", 19 | "FPS", 20 | "react-native" 21 | ], 22 | "license": "MIT", 23 | "engines": { 24 | "node": ">=10.15.0", 25 | "npm": ">=6.4.1" 26 | }, 27 | "scripts": { 28 | "test": "rimraf coverage && jest", 29 | "lint": "eslint .", 30 | "prepare": "husky install" 31 | }, 32 | "workspaces": [ 33 | "packages/*" 34 | ], 35 | "lint-staged": { 36 | "*.{json,md}": "prettier --write", 37 | "*.js": "eslint --cache --fix" 38 | }, 39 | "devDependencies": { 40 | "@babel/core": "^7.9.6", 41 | "@commitlint/cli": "^8.3.5", 42 | "@commitlint/config-conventional": "^8.3.4", 43 | "@react-native-async-storage/async-storage": "^1.16.0", 44 | "@react-native-community/eslint-config": "^1.1.0", 45 | "eslint": "6.8.0", 46 | "husky": "^7.0.0", 47 | "jest": "^26.6.3", 48 | "lerna": "3.22.0", 49 | "lint-staged": "^10.2.2", 50 | "postinstall-postinstall": "^2.1.0", 51 | "react-native-testing-library": "^1.13.2", 52 | "react-test-renderer": "^16.13.1", 53 | "rimraf": "^3.0.2" 54 | }, 55 | "resolutions": { 56 | "ansi-regex": "^5.0.1", 57 | "dot-prop": "^6.0.1", 58 | "glob-parent": "^5.1.2", 59 | "handlebars": "^4.7.7", 60 | "hermes-engine": "~0.9.0", 61 | "ini": "^1.3.8", 62 | "json-schema": "^0.4.0", 63 | "lodash": "^4.17.21", 64 | "logkitty": "^0.7.1", 65 | "metro": "^0.70.1", 66 | "node-fetch": "^2.6.1", 67 | "node-notifier": "^8.0.2", 68 | "plist": "^3.0.2", 69 | "set-value": "^4.0.1", 70 | "shell-quote": "^1.7.3", 71 | "simple-plist": "^1.3.1", 72 | "ssri": "^6.0.2", 73 | "trim-newlines": "^3.0.1", 74 | "trim-off-newlines": "^1.0.2", 75 | "ua-parser-js": "^0.7.28", 76 | "url-parse": "^1.5.2", 77 | "ws": "^7.4.6", 78 | "y18n": "^4.0.2", 79 | "yargs-parser": "^20.2.9" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/react-native-analytics/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [0.11.1](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-analytics@0.11.0...@farfetch/blackout-react-native-analytics@0.11.1) (2024-02-27) 7 | 8 | ### Bug Fixes 9 | 10 | - **analytics:** fix bugs with `basic` mode in FirebaseAnalytics ([cebbe9b](https://github.com/Farfetch/blackout-react-native/commit/cebbe9bf36740267693fec880f40d9990cad809c)) 11 | 12 | # [0.11.0](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-analytics@0.10.0...@farfetch/blackout-react-native-analytics@0.11.0) (2024-02-27) 13 | 14 | ### Features 15 | 16 | - **analytics:** add mode property to Firebase integration ([c8e4ac4](https://github.com/Farfetch/blackout-react-native/commit/c8e4ac48306e1cbb5942dc3e06e581dbca80a562)) 17 | 18 | # [0.10.0](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-analytics@0.9.0...@farfetch/blackout-react-native-analytics@0.10.0) (2024-02-26) 19 | 20 | ### Features 21 | 22 | - **analytics:** add support to consent mode in Firebase ([930bc79](https://github.com/Farfetch/blackout-react-native/commit/930bc796690074a63b2fe818ba405cf1f44772f0)) 23 | 24 | # [0.9.0](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-analytics@0.8.0...@farfetch/blackout-react-native-analytics@0.9.0) (2022-06-22) 25 | 26 | ### Features 27 | 28 | - **react-native-analytics:** update firebase mappings ([e2f6814](https://github.com/Farfetch/blackout-react-native/commit/e2f68146a735ca9b3637c7d46e5dd85c7df99729)) 29 | 30 | ### BREAKING CHANGES 31 | 32 | - **react-native-analytics:** Now all integrations will use the same events API 33 | that is being used by web applications. This means it is now possible 34 | to use the bag and wishlist redux middlewares as they will 35 | now be compatible with this implementation. 36 | Also, AnalyticsService integration was removed as it 37 | will not be supported in the future. 38 | 39 | # [0.8.0](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-analytics@0.7.0...@farfetch/blackout-react-native-analytics@0.8.0) (2022-04-13) 40 | 41 | ### Features 42 | 43 | - **react-native-analytics:** update Castle.io version ([431e5bf](https://github.com/Farfetch/blackout-react-native/commit/431e5bf7bb602edf8faa2763321fa4053dc9ec93)) 44 | 45 | # [0.7.0](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-analytics@0.6.3...@farfetch/blackout-react-native-analytics@0.7.0) (2022-02-25) 46 | 47 | ### Features 48 | 49 | - **react-native-analytics:** add react-native castle.io integration ([b76d92c](https://github.com/Farfetch/blackout-react-native/commit/b76d92c8fbb279860d96144766ac6d101aae6609)) 50 | 51 | ## [0.6.3](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-analytics@0.6.2...@farfetch/blackout-react-native-analytics@0.6.3) (2022-02-22) 52 | 53 | ### Bug Fixes 54 | 55 | - fix android crash and update async-storage package ([c7e14cb](https://github.com/Farfetch/blackout-react-native/commit/c7e14cb0c3f881dc3149cd75398bfc48886e78c8)) 56 | 57 | ## [0.6.2](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-analytics@0.6.1...@farfetch/blackout-react-native-analytics@0.6.2) (2021-11-25) 58 | 59 | ### Bug Fixes 60 | 61 | - **react-native-analytics:** fix integration imports ([ae565c7](https://github.com/Farfetch/blackout-react-native/commit/ae565c76ebe6e1441bc706672ce547b6ddbae670)) 62 | 63 | ## [0.6.1](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-analytics@0.6.0...@farfetch/blackout-react-native-analytics@0.6.1) (2021-11-25) 64 | 65 | **Note:** Version bump only for package @farfetch/blackout-react-native-analytics 66 | 67 | # 0.6.0 (2021-11-25) 68 | 69 | ### Features 70 | 71 | - migrate packages ([5a64fc5](https://github.com/Farfetch/blackout-react-native/commit/5a64fc58cb5f9cbdf600100f1c6315fa30889845)) 72 | -------------------------------------------------------------------------------- /packages/react-native-analytics/README.md: -------------------------------------------------------------------------------- 1 | # @farfetch/blackout-react-native-analytics 2 | 3 | Analytics solution for react-native apps 4 | 5 | ## Installation 6 | 7 | **yarn** 8 | 9 | ```sh 10 | yarn add @farfetch/blackout-react-native-analytics 11 | ``` 12 | 13 | **npm** 14 | 15 | ```sh 16 | npm i @farfetch/blackout-react-native-analytics 17 | ``` 18 | 19 | ### Peer dependencies 20 | 21 | Make sure that you have installed the correct peer dependencies of this package: 22 | 23 | - [`@farfetch/blackout-core`](https://www.npmjs.com/package/@farfetch/blackout-core) 24 | - [`@react-native-firebase/analytics`](https://www.npmjs.com/package/@react-native-firebase/analytics) (Only necessary if you need to use Firebase integration). 25 | - `react-native-forter` (Only necessary if you need to use Forter integration. Contact Forter to know how to install this package as it is not public.) 26 | 27 | ## Usage 28 | 29 | Set the storage in analytics by calling `analytics.setStorage` with an instance that support the `getItem(key)`, `setItem(key, value)` and `removeItem(key)` methods: 30 | 31 | ```js 32 | import analytics from '@farfetch/blackout-react-native-analytics'; 33 | 34 | // `AsyncStorage` implements `getItem(key)`, `setItem(key, value)` and `removeItem(key)` used by analytics. 35 | // `Analytics` will await these calls if they are async. 36 | import AsyncStorage from '@react-native-async-storage/async-storage'; 37 | 38 | analytics.setStorage(AsyncStorage); 39 | ``` 40 | 41 | Add integrations to analytics that will enable your tracking information to be sent to other providers. 42 | There are some integrations that are provided by this package and are ready to be used: 43 | 44 | ```js 45 | import analytics, { 46 | integrations, 47 | } from '@farfetch/blackout-react-native-analytics'; 48 | 49 | // This will add the `Omnitracking` integration to analytics that will 50 | // enable your tracking data to be sent to the `Omnitracking` service. 51 | analytics.addIntegration('omnitracking', integrations.Omnitracking); 52 | ``` 53 | 54 | Add contexts that are required by the integrations. There are some contexts that are provided by this package and are ready to be used: 55 | 56 | ```js 57 | import analytics, { contexts } from '@farfetch/blackout-react-native-analytics'; 58 | 59 | // This context will add a GUID to the storage that will persist while the app is installed on the device. 60 | // This GUID will be available to all integrations configured on the `context.app.clientInstallId` key of the event payload. 61 | analytics.useContext(contexts.getClientInstallIdContext()); 62 | ``` 63 | 64 | Call the `analytics.ready` method after finishing analytics configuration: 65 | 66 | ```js 67 | // `ready` returns a promise that can be awaited if you need to wait until the method finishes. 68 | await analytics.ready(); 69 | ``` 70 | 71 | After this point you can start using the methods `analytics.screen` and `analytics.track` to register screen views and events respectively with analytics: 72 | 73 | ```js 74 | import { 75 | eventTypes, 76 | screenTypes, 77 | } from '@farfetch/blackout-react-native-analytics'; 78 | 79 | // Tracks a screen view. You can send additional properties to the events that will be available to use by all integrations. 80 | analytics.screen(screenTypes.PRODUCT_DETAILS, { productId: 100000 }); 81 | 82 | // Tracks an event. You can send additional properties to the events that will be available to use by all integrations. 83 | analytics.track(eventTypes.PRODUCT_VIEWED, { 84 | cartId: '787f1f77b87453d799430941', 85 | id: '507f1f77bcf86cd799439011', 86 | sku: 'G-32', 87 | category: 'Clothing/Tops/T-shirts', 88 | name: 'Gareth McConnell Dreamscape T-Shirt', 89 | brand: 'Just A T-Shirt', 90 | variant: 'Black', 91 | size: 'L', 92 | price: 18.99, 93 | quantity: 1, 94 | currency: 'USD', 95 | }); 96 | ``` 97 | 98 | Lastly, you will need to make sure that a user is set in analytics by calling the `analytics.setUser` in order for your previous `analytics.track` or `analytics.screen` to be dispatched to the integrations. 99 | Analytics needs a user to be set in order to be able to associate a user to these events. 100 | 101 | ```js 102 | // `setUser` returns a promise that can be awaited if you need to wait until the method finishes. 103 | await analytics.setUser(680968743, { 104 | username: 'George', 105 | email: 'george@company.com', 106 | isGuest: false, 107 | membership: [], 108 | segments: [], 109 | gender: 1, 110 | bagId: '1ff36cd1-0dac-497f-8f32-4f2f7bdd2eaf', 111 | }); 112 | ``` 113 | 114 | ### Consent 115 | 116 | Some integrations need consent from the user to be loaded by analytics. 117 | To manage the consent in analytics, you can use the `analytics.setConsent` method. 118 | This method accepts an object with the keys `statistics`, `preferences` and `marketing` whose values indicate the consent the user has given to each category. 119 | 120 | ```js 121 | // User has given consent to marketing and statistics tracking and not preferences. 122 | await analytics.setConsent({ 123 | marketing: true, 124 | statistics: true, 125 | preferences: false, 126 | }); 127 | ``` 128 | 129 | ## Contributing 130 | 131 | Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change. 132 | 133 | Please read the [CONTRIBUTING](../../CONTRIBUTING.md) file to know what we expect from your contribution and the guidelines you should follow. 134 | 135 | ## License 136 | 137 | [MIT](../../LICENSE) @ Farfetch 138 | -------------------------------------------------------------------------------- /packages/react-native-analytics/__mocks__/react-native-forter.js: -------------------------------------------------------------------------------- 1 | export const forterSDK = { 2 | getDeviceUniqueID: jest.fn(), 3 | init: jest.fn((siteId, mobileUid, successCallback) => { 4 | successCallback(); 5 | }), 6 | setAccountIdentifier: jest.fn(), 7 | trackNavigation: jest.fn(), 8 | trackNavigationWithExtraData: jest.fn(), 9 | trackActionWithJSON: jest.fn(), 10 | trackAction: jest.fn(), 11 | }; 12 | 13 | export const ForterNavigationType = { 14 | PRODUCT: 'PRODUCT', 15 | ACCOUNT: 'ACCOUNT', 16 | SEARCH: 'SEARCH', 17 | CHECKOUT: 'CHECKOUT', 18 | CART: 'CART', 19 | HELP: 'HELP', 20 | APP: 'APP', 21 | }; 22 | 23 | export const ForterActionType = { 24 | TAP: 'TAP', 25 | CLIPBOARD: 'CLIPBOARD', 26 | TYPING: 'TYPING', 27 | ADD_TO_CART: 'ADD_TO_CART', 28 | REMOVE_FROM_CART: 'REMOVE_FROM_CART', 29 | ACCEPTED_PROMOTION: 'ACCEPTED_PROMOTION', 30 | ACCEPTED_TOS: 'ACCEPTED_TOS', 31 | ACCOUNT_LOGIN: 'ACCOUNT_LOGIN', 32 | ACCOUNT_LOGOUT: 'ACCOUNT_LOGOUT', 33 | ACCOUNT_ID_ADDED: 'ACCOUNT_ID_ADDED', 34 | PAYMENT_INFO: 'PAYMENT_INFO', 35 | SHARE: 'SHARE', 36 | CONFIGURATION_UPDATE: 'CONFIGURATION_UPDATE', 37 | APP_ACTIVE: 'APP_ACTIVE', 38 | APP_PAUSE: 'APP_PAUSE', 39 | RATE: 'RATE', 40 | IS_JAILBROKEN: 'IS_JAILBROKEN', 41 | SEARCH_QUERY: 'SEARCH_QUERY', 42 | REFERRER: 'REFERRER', 43 | WEBVIEW_TOKEN: 'WEBVIEW_TOKEN', 44 | OTHER: 'OTHER', 45 | }; 46 | 47 | export const ForterAccountType = { 48 | MERCHANT: 'MERCHANT', 49 | FACEBOOK: 'FACEBOOK', 50 | GOOGLE: 'GOOGLE', 51 | TWITTER: 'TWITTER', 52 | APPLE_IDFA: 'APPLE_IDFA', 53 | OTHER: 'OTHER', 54 | }; 55 | -------------------------------------------------------------------------------- /packages/react-native-analytics/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@farfetch/blackout-react-native-analytics", 3 | "version": "0.11.1", 4 | "description": "Analytics solution for react-native apps", 5 | "main": "src/index.js", 6 | "files": [ 7 | "src" 8 | ], 9 | "license": "MIT", 10 | "dependencies": { 11 | "uuid": "^3.3.2" 12 | }, 13 | "devDependencies": { 14 | "@castleio/react-native-castle": "^1.1.5", 15 | "@farfetch/blackout-core": "^1.79.0", 16 | "@react-native-async-storage/async-storage": "^1.16.0", 17 | "@react-native-firebase/analytics": "^18.9.0", 18 | "@react-native-firebase/app": "^18.9.0", 19 | "axios": "^0.21.4", 20 | "react": "^16.8.1", 21 | "react-native": "^0.62.3", 22 | "react-native-device-info": "^5.5.8" 23 | }, 24 | "peerDependencies": { 25 | "@castleio/react-native-castle": "^1.1.5", 26 | "@farfetch/blackout-core": "^1.79.0", 27 | "@react-native-async-storage/async-storage": "^1.6.1", 28 | "@react-native-firebase/analytics": "^18.9.0", 29 | "react-native": "^0.62.3", 30 | "react-native-device-info": "^5.5.8", 31 | "react-native-forter": "^0.1.10" 32 | }, 33 | "publishConfig": { 34 | "access": "public" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/__tests__/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should export the eventTypes on the eventTypes key 1`] = ` 4 | Object { 5 | "ADDRESS_INFO_ADDED": "Address Info Added", 6 | "APP_CLOSED": "App Closed", 7 | "APP_OPENED": "App Opened", 8 | "BILLING_INFO_ADDED": "Billing Info Added", 9 | "CHECKOUT_ABANDONED": "Checkout Abandoned", 10 | "CHECKOUT_STARTED": "Checkout Started", 11 | "CHECKOUT_STEP_COMPLETED": "Checkout Step Completed", 12 | "CHECKOUT_STEP_EDITING": "Checkout Step Editing", 13 | "CHECKOUT_STEP_VIEWED": "Checkout Step Viewed", 14 | "DELIVERY_METHOD_ADDED": "Delivery Method Added", 15 | "FILTERS_APPLIED": "Filters Applied", 16 | "FILTERS_CLEARED": "Filters Cleared", 17 | "INTERACT_CONTENT": "Interact Content", 18 | "LOGIN": "Login", 19 | "LOGOUT": "Logout", 20 | "ORDER_COMPLETED": "Order Completed", 21 | "ORDER_REFUNDED": "Order Refunded", 22 | "PAYMENT_INFO_ADDED": "Payment Info Added", 23 | "PLACE_ORDER_FAILED": "Place Order Failed", 24 | "PLACE_ORDER_STARTED": "Place Order Started", 25 | "PRODUCT_ADDED_TO_CART": "Product Added to Cart", 26 | "PRODUCT_ADDED_TO_WISHLIST": "Product Added to Wishlist", 27 | "PRODUCT_CLICKED": "Product Clicked", 28 | "PRODUCT_LIST_VIEWED": "Product List Viewed", 29 | "PRODUCT_REMOVED_FROM_CART": "Product Removed from Cart", 30 | "PRODUCT_REMOVED_FROM_WISHLIST": "Product Removed From Wishlist", 31 | "PRODUCT_UPDATED": "Product Updated", 32 | "PRODUCT_UPDATED_WISHLIST": "Product Updated In Wishlist", 33 | "PRODUCT_VIEWED": "Product Viewed", 34 | "PROMOCODE_APPLIED": "Promocode Applied", 35 | "SELECT_CONTENT": "Select Content", 36 | "SHARE": "Share", 37 | "SHIPPING_INFO_ADDED": "Shipping Info Added", 38 | "SHIPPING_METHOD_ADDED": "Shipping Method Added", 39 | "SIGNUP_FORM_COMPLETED": "Sign-up Form Completed", 40 | "SIGNUP_FORM_VIEWED": "Sign-up Form Viewed", 41 | "SIGNUP_NEWSLETTER": "Sign-up Newsletter", 42 | "SITE_PERFORMANCE": "Site Performance", 43 | } 44 | `; 45 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/__tests__/analytics.test.js: -------------------------------------------------------------------------------- 1 | import analytics from '../analytics'; 2 | import AnalyticsCore, { 3 | integrations, 4 | trackTypes, 5 | } from '@farfetch/blackout-core/analytics'; 6 | import TestStorage from 'test-storage'; 7 | 8 | class MyIntegration extends integrations.Integration { 9 | static shouldLoad() { 10 | return true; 11 | } 12 | 13 | static createInstance() { 14 | return new MyIntegration(); 15 | } 16 | 17 | track() {} 18 | } 19 | 20 | class IntegrationThatNeedsConsent extends integrations.Integration { 21 | static shouldLoad(consent) { 22 | return !!consent && !!consent.statistics; 23 | } 24 | 25 | static createInstance() { 26 | return new IntegrationThatNeedsConsent(); 27 | } 28 | 29 | track = jest.fn(); 30 | } 31 | 32 | describe('analytics react native', () => { 33 | it('Should extend the core analytics', () => { 34 | expect(analytics).toBeInstanceOf(AnalyticsCore); 35 | }); 36 | 37 | describe('instance methods', () => { 38 | beforeEach(async () => { 39 | analytics.isReady = false; 40 | analytics.integrations.clear(); 41 | analytics.currentScreenCallData = null; 42 | jest.clearAllMocks(); 43 | 44 | await analytics.setStorage(new TestStorage()); 45 | await analytics.setUser(123); 46 | }); 47 | 48 | it('Should expose `track` and `screen` methods that will make use of the analytics core track method', async () => { 49 | analytics.addIntegration('myIntegration', MyIntegration); 50 | 51 | await analytics.ready(); 52 | 53 | const integrationInstance = analytics.integration('myIntegration'); 54 | 55 | expect(integrationInstance).not.toBe(null); 56 | 57 | const coreTrackSpy = jest.spyOn(AnalyticsCore.prototype, 'track'); 58 | 59 | const integrationInstanceTrackSpy = jest.spyOn( 60 | integrationInstance, 61 | 'track', 62 | ); 63 | 64 | const event = 'myEvent'; 65 | const properties = { prop1: 'prop1' }; 66 | const eventContext = { culture: 'pt-PT' }; //Simulate that the event has a different culture associated with it. 67 | 68 | await analytics.track(event, properties, eventContext); 69 | 70 | expect(coreTrackSpy).toHaveBeenCalledWith( 71 | trackTypes.TRACK, 72 | event, 73 | properties, 74 | eventContext, 75 | ); 76 | 77 | expect(integrationInstanceTrackSpy).toHaveBeenCalledWith( 78 | expect.objectContaining({ 79 | type: trackTypes.TRACK, 80 | event, 81 | properties, 82 | context: expect.objectContaining({ 83 | event: expect.objectContaining({ 84 | ...eventContext, 85 | __blackoutAnalyticsEventId: expect.any(String), 86 | }), 87 | }), 88 | }), 89 | ); 90 | 91 | jest.clearAllMocks(); 92 | 93 | await analytics.screen(event, properties, eventContext); 94 | 95 | expect(coreTrackSpy).toHaveBeenCalledWith( 96 | trackTypes.SCREEN, 97 | event, 98 | properties, 99 | eventContext, 100 | ); 101 | 102 | expect(integrationInstanceTrackSpy).toHaveBeenCalledWith( 103 | expect.objectContaining({ 104 | type: trackTypes.SCREEN, 105 | event, 106 | properties, 107 | context: expect.objectContaining({ 108 | event: expect.objectContaining({ 109 | ...eventContext, 110 | __blackoutAnalyticsEventId: expect.any(String), 111 | }), 112 | }), 113 | }), 114 | ); 115 | }); 116 | 117 | describe('When setConsent is called', () => { 118 | it('If there was a screen call made, it should call the track method with this screen data of integrations that are loaded by the given consent', async () => { 119 | const integrationThatNeedsConsentKey = 'integrationThatNeedsConsent'; 120 | 121 | analytics.addIntegration('myIntegration', MyIntegration); 122 | analytics.addIntegration( 123 | integrationThatNeedsConsentKey, 124 | IntegrationThatNeedsConsent, 125 | ); 126 | 127 | await analytics.ready(); 128 | 129 | const myIntegrationInstance = analytics.integration('myIntegration'); 130 | 131 | jest.spyOn(myIntegrationInstance, 'track'); 132 | 133 | let integrationThatNeedsConsentInstance = analytics.integration( 134 | integrationThatNeedsConsentKey, 135 | ); 136 | 137 | expect(integrationThatNeedsConsentInstance).toBe(null); 138 | 139 | const screenCallData = { 140 | event: 'Home', 141 | properties: { prop1: 'prop1', prop2: 'prop2' }, 142 | }; 143 | 144 | await analytics.screen(screenCallData.event, screenCallData.properties); 145 | 146 | expect(myIntegrationInstance.track).toHaveBeenCalled(); 147 | 148 | //We need to clear all mocks because the setConsent call will call the track method of the loaded integrations 149 | //and we need to check that the already loaded ones does not get its track method called again. 150 | jest.clearAllMocks(); 151 | 152 | await analytics.setConsent({ statistics: true }); 153 | 154 | //myIntegrationInstance was already loaded before consent was given, so it shouldn't have its track method called here 155 | expect(myIntegrationInstance.track).not.toHaveBeenCalled(); 156 | 157 | integrationThatNeedsConsentInstance = analytics.integration( 158 | integrationThatNeedsConsentKey, 159 | ); 160 | 161 | expect(integrationThatNeedsConsentInstance).not.toBe(null); 162 | 163 | expect(integrationThatNeedsConsentInstance.track).toHaveBeenCalledWith( 164 | expect.objectContaining(screenCallData), 165 | ); 166 | }); 167 | 168 | it('If there was not a screen call made, it should not call the track method of integrations that are loaded by the given consent', async () => { 169 | const integrationThatNeedsConsentKey = 'integrationThatNeedsConsent'; 170 | 171 | analytics.addIntegration( 172 | integrationThatNeedsConsentKey, 173 | IntegrationThatNeedsConsent, 174 | ); 175 | 176 | await analytics.ready(); 177 | 178 | let integrationThatNeedsConsentInstance = analytics.integration( 179 | integrationThatNeedsConsentKey, 180 | ); 181 | 182 | expect(integrationThatNeedsConsentInstance).toBe(null); 183 | 184 | await analytics.setConsent({ statistics: true }); 185 | 186 | integrationThatNeedsConsentInstance = analytics.integration( 187 | integrationThatNeedsConsentKey, 188 | ); 189 | 190 | expect(integrationThatNeedsConsentInstance).not.toBe(null); 191 | 192 | expect( 193 | integrationThatNeedsConsentInstance.track, 194 | ).not.toHaveBeenCalled(); 195 | }); 196 | }); 197 | 198 | describe('context', () => { 199 | it('Should return some default values', async () => { 200 | const context = await analytics.context(); 201 | 202 | expect(context).toStrictEqual({ 203 | device: 'iPhone12,5', 204 | deviceLanguage: 'en', 205 | deviceOS: 'iOS 13.0', 206 | library: { 207 | name: '@farfetch/blackout-core/analytics', 208 | version: expect.any(String), 209 | }, 210 | screenHeight: 1000, 211 | screenWidth: 800, 212 | }); 213 | }); 214 | }); 215 | }); 216 | }); 217 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | import * as mainExports from '..'; 2 | import analytics from '../analytics'; 3 | import screenTypes from '../screenTypes'; 4 | import { trackTypes } from '@farfetch/blackout-core/analytics'; 5 | 6 | jest.mock('@react-native-firebase/analytics', () => ({})); 7 | 8 | it('Should export the analytics instance on the default export', () => { 9 | expect(mainExports.default).toBe(analytics); 10 | }); 11 | 12 | it('Should export the eventTypes on the eventTypes key', () => { 13 | expect(mainExports.eventTypes).toMatchSnapshot(); 14 | }); 15 | 16 | it('Should export the trackTypes on trackTypes key', () => { 17 | expect(mainExports.trackTypes).toBe(trackTypes); 18 | }); 19 | 20 | it('Should export the screenTypes on screenTypes key', () => { 21 | expect(mainExports.screenTypes).toBe(screenTypes); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/analytics.js: -------------------------------------------------------------------------------- 1 | import AnalyticsCore, { 2 | trackTypes, 3 | platformTypes, 4 | } from '@farfetch/blackout-core/analytics'; 5 | 6 | import * as contexts from './contexts'; 7 | 8 | class AnalyticsNative extends AnalyticsCore { 9 | constructor() { 10 | super(platformTypes.Mobile); 11 | 12 | // Stores the last page call 13 | this.currentScreenCallData = null; 14 | 15 | // Add default contexts for the mobile platform 16 | this.useContext(contexts.deviceLanguageContext); 17 | this.useContext(contexts.deviceModelContext); 18 | this.useContext(contexts.deviceOSContext); 19 | this.useContext(contexts.screenDimensionsContext); 20 | } 21 | 22 | /** 23 | * Whenever the integrations are loaded at a certain point in time, we fetch them and send the current tracked screen information. 24 | * This can happen whenever the user gives consent for a specific category mid session. 25 | * 26 | * @param {Array} loadedIntegrations - An array that contains the integrations that were loaded in runtime, namely after setConsent is called. 27 | * 28 | * @async 29 | * @returns {Promise} - Promise that will resolve when the method finishes. 30 | * 31 | * @memberof AnalyticsNative# 32 | */ 33 | async onLoadedIntegrations(loadedIntegrations) { 34 | // If there is a previous screen call data stored, send a screen event to the integrations that were loaded 35 | if (this.currentScreenCallData) { 36 | const { name, properties, eventContext } = this.currentScreenCallData; 37 | 38 | const screenEventData = await super.getTrackEventData( 39 | trackTypes.SCREEN, 40 | name, 41 | properties, 42 | eventContext, 43 | ); 44 | 45 | super.callIntegrationsMethod( 46 | loadedIntegrations, 47 | 'track', 48 | screenEventData, 49 | ); 50 | } 51 | } 52 | 53 | /** 54 | * Track method for custom events. 55 | * 56 | * @param {String} event - Name of the event. 57 | * @param {Object} properties - Properties of the event. 58 | * @param {Object} eventContext - Context data that is specific for this event. 59 | * 60 | * @async 61 | * @returns {Promise} - Promise that will resolve with the analytics instance. 62 | * 63 | * @memberof AnalyticsNative# 64 | */ 65 | async track(event, properties, eventContext) { 66 | await super.track(trackTypes.TRACK, event, properties, eventContext); 67 | 68 | return this; 69 | } 70 | 71 | /** 72 | * Tracks a screen view and keeps the last call in case 73 | * there are new integrations that are loaded mid-session 74 | * ,i.e. the user gives consent. 75 | * 76 | * @param {String} name - Name of the screen that is to be tracked. 77 | * @param {Object} properties - Properties associated with the screen view. 78 | * @param {Object} eventContext - Context data that is specific for this event. 79 | * 80 | * @async 81 | * @returns {Promise} - Promise that will resolve with the analytics instance. 82 | * 83 | * @memberof AnalyticsNative# 84 | */ 85 | async screen(name, properties, eventContext) { 86 | // Override the last screen call data with the current one 87 | this.currentScreenCallData = { 88 | name, 89 | properties, 90 | eventContext, 91 | }; 92 | 93 | await super.track(trackTypes.SCREEN, name, properties, eventContext); 94 | return this; 95 | } 96 | } 97 | 98 | export default new AnalyticsNative(); 99 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/contexts/__tests__/deviceLanguageContext.test.js: -------------------------------------------------------------------------------- 1 | import deviceLanguageContext from '../deviceLanguageContext'; 2 | import { NativeModules, Platform } from 'react-native'; 3 | 4 | describe('deviceLanguageContext', () => { 5 | describe('iOS platform', () => { 6 | beforeAll(() => { 7 | Platform.OS = 'ios'; 8 | }); 9 | 10 | it('Should use the AppleLocale value if available as the value for deviceLanguage', () => { 11 | const expectedContext = { 12 | deviceLanguage: NativeModules.SettingsManager.settings.AppleLocale, 13 | }; 14 | 15 | const context = deviceLanguageContext(); 16 | 17 | expect(context).toStrictEqual(expectedContext); 18 | }); 19 | 20 | it('Should use the AppleLanguages array if AppleLocale is not available as the value for the deviceLanguage', () => { 21 | //Remove AppleLocale from settings. 22 | delete NativeModules.SettingsManager.settings.AppleLocale; 23 | 24 | const expectedContext = { 25 | deviceLanguage: 26 | NativeModules.SettingsManager.settings.AppleLanguages[0], 27 | }; 28 | 29 | const context = deviceLanguageContext(); 30 | 31 | expect(context).toStrictEqual(expectedContext); 32 | }); 33 | 34 | it('Should return a default value if no language was found on the device', () => { 35 | //Remove AppleLanguages to trigger the default case 36 | delete NativeModules.SettingsManager.settings.AppleLanguages; 37 | 38 | const expectedContext = { 39 | deviceLanguage: 'en', 40 | }; 41 | 42 | const context = deviceLanguageContext(); 43 | 44 | expect(context).toStrictEqual(expectedContext); 45 | }); 46 | }); 47 | 48 | describe('Android platform', () => { 49 | beforeAll(() => { 50 | Platform.OS = 'android'; 51 | }); 52 | 53 | it('Should use the NativeModules.I18nManager.localeIdentifier as the value for the deviceLanguage', () => { 54 | const expectedContext = { 55 | deviceLanguage: NativeModules.I18nManager.localeIdentifier, 56 | }; 57 | 58 | const context = deviceLanguageContext(); 59 | 60 | expect(context).toStrictEqual(expectedContext); 61 | }); 62 | 63 | it('Should return a default value if no language was found on the device', () => { 64 | //Remove localeIdentifier to trigger the default case 65 | delete NativeModules.I18nManager.localeIdentifier; 66 | 67 | const expectedContext = { 68 | deviceLanguage: 'en', 69 | }; 70 | 71 | const context = deviceLanguageContext(); 72 | 73 | expect(context).toStrictEqual(expectedContext); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/contexts/__tests__/deviceModelContext.test.js: -------------------------------------------------------------------------------- 1 | import deviceModelContext from '../deviceModelContext'; 2 | import { getModel, getDeviceId } from 'react-native-device-info'; 3 | 4 | describe('deviceModelContext', () => { 5 | it('Should return the model that is available in getModel if it is not unknown', () => { 6 | const model = getModel(); 7 | 8 | const expectedContext = { 9 | device: model, 10 | }; 11 | 12 | const context = deviceModelContext(); 13 | 14 | expect(context).toStrictEqual(expectedContext); 15 | }); 16 | 17 | it('Should return the deviceId that is available in getDeviceId if getModel returns unknown', () => { 18 | getModel.mockImplementation(() => 'unknown'); 19 | 20 | const deviceId = getDeviceId(); 21 | 22 | const expectedContext = { 23 | device: deviceId, 24 | }; 25 | 26 | const context = deviceModelContext(); 27 | 28 | expect(context).toStrictEqual(expectedContext); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/contexts/__tests__/deviceOSContext.test.js: -------------------------------------------------------------------------------- 1 | import { getSystemName, getSystemVersion } from 'react-native-device-info'; 2 | import deviceOSContext from '../deviceOSContext'; 3 | 4 | describe('deviceOSContext', () => { 5 | it('Should return the values from getSystemName and getSystemVersion as the deviceOS value', () => { 6 | const expectedContext = { 7 | deviceOS: `${getSystemName()} ${getSystemVersion()}`, 8 | }; 9 | 10 | const context = deviceOSContext(); 11 | 12 | expect(context).toStrictEqual(expectedContext); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/contexts/__tests__/getClientInstallIdContext.test.js: -------------------------------------------------------------------------------- 1 | import getClientInstallIdContext, { 2 | ClientInstallIdDefaultKey, 3 | } from '../getClientInstallIdContext'; 4 | import AsyncStorage from '@react-native-async-storage/async-storage'; 5 | 6 | describe('getClientInstallIdContext', () => { 7 | beforeEach(() => { 8 | jest.clearAllMocks(); 9 | }); 10 | 11 | it('Should create a context function when no options are passed', async () => { 12 | const contextFn = getClientInstallIdContext(); 13 | 14 | expect(typeof contextFn).toBe('function'); 15 | 16 | const context = await contextFn(); 17 | 18 | expect(context).toEqual( 19 | expect.objectContaining({ 20 | app: { clientInstallId: expect.any(String) }, 21 | }), 22 | ); 23 | 24 | expect(AsyncStorage.setItem).toHaveBeenCalledWith( 25 | ClientInstallIdDefaultKey, 26 | expect.any(String), 27 | ); 28 | }); 29 | 30 | it('Should allow to specify the key where the clientInstallId will be stored', async () => { 31 | const storageKey = 'my-storage-key'; 32 | 33 | const contextFn = getClientInstallIdContext({ storageKey }); 34 | 35 | expect(typeof contextFn).toBe('function'); 36 | 37 | const context = await contextFn(); 38 | 39 | expect(context).toEqual( 40 | expect.objectContaining({ 41 | app: { clientInstallId: expect.any(String) }, 42 | }), 43 | ); 44 | 45 | expect(AsyncStorage.setItem).toHaveBeenCalledWith( 46 | storageKey, 47 | expect.any(String), 48 | ); 49 | }); 50 | 51 | it('Should allow to specify a storage instance where clientInstallId will be stored', async () => { 52 | const DummyStorage = class { 53 | setItem = jest.fn(); 54 | getItem = jest.fn(); 55 | removeItem = jest.fn(); 56 | }; 57 | 58 | const storage = new DummyStorage(); 59 | 60 | const contextFn = getClientInstallIdContext({ storage }); 61 | 62 | expect(typeof contextFn).toBe('function'); 63 | 64 | await contextFn(); 65 | 66 | expect(storage.setItem).toHaveBeenCalledWith( 67 | ClientInstallIdDefaultKey, 68 | expect.any(String), 69 | ); 70 | }); 71 | 72 | it('Should return the same clientInstallId whenever called', async () => { 73 | const contextFn = getClientInstallIdContext(); 74 | 75 | let context = await contextFn(); 76 | 77 | const { 78 | app: { clientInstallId: previousClientInstallId }, 79 | } = context; 80 | 81 | context = await contextFn(); 82 | 83 | const { 84 | app: { clientInstallId: currentClientInstallId }, 85 | } = context; 86 | 87 | expect(previousClientInstallId === currentClientInstallId).toBe(true); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/contexts/__tests__/screenDimensionsContext.test.js: -------------------------------------------------------------------------------- 1 | import { Dimensions } from 'react-native'; 2 | import screenDimensionsContext from '../screenDimensionsContext'; 3 | 4 | describe('screenDimensionsContext', () => { 5 | it('Should return the screen dimensions that are in Dimensions.get("window")', () => { 6 | const windowDimensions = Dimensions.get('window'); 7 | 8 | const contextWithWindowDimensions = { 9 | screenWidth: windowDimensions.width, 10 | screenHeight: windowDimensions.height, 11 | }; 12 | 13 | const screenDimensions = Dimensions.get('screen'); 14 | 15 | const contextWithScreenDimensions = { 16 | screenWidth: screenDimensions.width, 17 | screenHeight: screenDimensions.height, 18 | }; 19 | 20 | const context = screenDimensionsContext(); 21 | 22 | expect(context).toStrictEqual(contextWithWindowDimensions); 23 | 24 | expect(context).not.toStrictEqual(contextWithScreenDimensions); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/contexts/deviceLanguageContext.js: -------------------------------------------------------------------------------- 1 | import { NativeModules, Platform } from 'react-native'; 2 | 3 | /** 4 | * Obtains the device language for iOS and Android platforms 5 | * and returns a default language when not available. 6 | * 7 | * @returns {String} - The language of the operating system on the device or 'en' as default value if no value was found on the device 8 | */ 9 | function getDeviceLocale() { 10 | let locale; 11 | 12 | if (Platform.OS === 'ios') { 13 | locale = NativeModules.SettingsManager.settings.AppleLocale; 14 | 15 | if (locale === undefined) { 16 | // iOS 13 workaround, take first of AppleLanguages array ["en", "en-NZ"] 17 | const appleLanguages = 18 | NativeModules.SettingsManager.settings.AppleLanguages; 19 | 20 | if (appleLanguages && appleLanguages.length) { 21 | locale = NativeModules.SettingsManager.settings.AppleLanguages[0]; 22 | } 23 | 24 | if (locale) { 25 | return locale; 26 | } 27 | } 28 | } 29 | 30 | if (Platform.OS === 'android') { 31 | locale = NativeModules.I18nManager.localeIdentifier; 32 | 33 | if (locale) { 34 | return locale; 35 | } 36 | } 37 | 38 | //Return en by default if no language was obtained 39 | return 'en'; 40 | } 41 | 42 | /** 43 | * Function that adds a deviceLanguage property with the language of the operating system of the device 44 | * to be used on analytics.useContext method. 45 | * 46 | * @returns {Object} - An object with a key 'deviceLanguage' that corresponds to the language of the operating system of the device or 'en' if not found. 47 | */ 48 | export default function deviceLanguageContext() { 49 | return { 50 | deviceLanguage: getDeviceLocale(), 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/contexts/deviceModelContext.js: -------------------------------------------------------------------------------- 1 | import { getModel, getDeviceId } from 'react-native-device-info'; 2 | 3 | /** 4 | * Function that adds a device property with the model of the device 5 | * to be used on analytics.useContext method. 6 | * For iOS devices, the model name is obtained from a user-maintained 7 | * list of models. If a model name is not available, will use the deviceId as the device model. 8 | * 9 | * @returns {Object} - An object with a key 'device' that corresponds to the device model. 10 | */ 11 | export default function deviceModelContext() { 12 | let model = getModel(); 13 | 14 | //For iOS devices, the model is obtained from a user-maintained 15 | //list of models, so if the model of the iOS device is not found 16 | //we return the deviceId as specified in react-native-device-info docs. 17 | if (model === 'unknown') { 18 | model = getDeviceId(); 19 | } 20 | 21 | return { device: model }; 22 | } 23 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/contexts/deviceOSContext.js: -------------------------------------------------------------------------------- 1 | import { getSystemName, getSystemVersion } from 'react-native-device-info'; 2 | 3 | /** 4 | * Function that adds a deviceOS property with the name and version of the operating system 5 | * of the device to be used on analytics.useContext method. 6 | * 7 | * @returns {Object} - An object with a key 'deviceOS' that corresponds to the name and version of the operating system. 8 | */ 9 | export default function deviceOSContext() { 10 | const systemName = getSystemName(); 11 | const systemVersion = getSystemVersion(); 12 | 13 | return { deviceOS: `${systemName} ${systemVersion}` }; 14 | } 15 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/contexts/getClientInstallIdContext.js: -------------------------------------------------------------------------------- 1 | import AsyncStorage from '@react-native-async-storage/async-storage'; 2 | import { name } from '../../package.json'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | export const ClientInstallIdDefaultKey = `${name}/clientInstallIdContext`; 6 | 7 | /** 8 | * Returns a context function that will add a clientInstallId 9 | * property to the context when added with analytics.useContext method. 10 | * The clientInstallId is a UUID that identifies an installation 11 | * of the app and will only change when the app is installed 12 | * again. This id will be stored with @react-native-async-storage/async-storage 13 | * by default. 14 | * 15 | * @param {Object} [options] - Options object to configure the context function that will be returned. 16 | * @param {Object} [options.storage] - The storage instance to use to store the clientInstallId. This instance must provide the methods setItem(key, value) and getItem(key) to work. By default will use AsyncStorage from '@react-native-async-storage/async-storage' package. 17 | * @param {String} [options.storageKey] - The name of the key where the id will be stored on the storage. By default it is the '${packageName}/clientInstallIdContext'. 18 | * 19 | * @returns {Function} - An async context function to be used in analytics.useContext method. 20 | */ 21 | export default function getClientInstallIdContext(options = {}) { 22 | const storageImplementation = options.storage || AsyncStorage; 23 | const storageKey = options.storageKey || ClientInstallIdDefaultKey; 24 | 25 | let clientInstallId; 26 | 27 | return async function getClientInstallId() { 28 | if (!clientInstallId) { 29 | const storedClientInstallId = await storageImplementation.getItem( 30 | storageKey, 31 | ); 32 | 33 | if (storedClientInstallId) { 34 | clientInstallId = storedClientInstallId; 35 | } else { 36 | clientInstallId = uuidv4(); 37 | await storageImplementation.setItem(storageKey, clientInstallId); 38 | } 39 | } 40 | 41 | return { 42 | app: { 43 | clientInstallId, 44 | }, 45 | }; 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/contexts/index.js: -------------------------------------------------------------------------------- 1 | export { default as deviceLanguageContext } from './deviceLanguageContext'; 2 | export { default as deviceModelContext } from './deviceModelContext'; 3 | export { default as deviceOSContext } from './deviceOSContext'; 4 | export { default as getClientInstallIdContext } from './getClientInstallIdContext'; 5 | export { default as screenDimensionsContext } from './screenDimensionsContext'; 6 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/contexts/screenDimensionsContext.js: -------------------------------------------------------------------------------- 1 | import { Dimensions } from 'react-native'; 2 | 3 | /** 4 | * Function that adds screenWidth and screenHeight properties that 5 | * correspond to the application's window dimensions 6 | * as returned by Dimensions.get('window') method. This function 7 | * is to be used in analytics.useContext method. 8 | * 9 | * @returns {Object} - An object with keys 'screenWidth' and 'screenHeight' that correspond to the application's window dimensions. 10 | */ 11 | export default function screenDimensionsContext() { 12 | const windowDimensions = Dimensions.get('window'); 13 | 14 | return { 15 | screenWidth: windowDimensions.width, 16 | screenHeight: windowDimensions.height, 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/eventTypes.js: -------------------------------------------------------------------------------- 1 | import { eventTypes } from '@farfetch/blackout-core/analytics'; 2 | 3 | /** 4 | * Extend the core eventTypes with app specific ones 5 | */ 6 | export default { 7 | ...eventTypes, 8 | APP_OPENED: 'App Opened', 9 | APP_CLOSED: 'App Closed', 10 | }; 11 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/index.js: -------------------------------------------------------------------------------- 1 | import analyticsNative from './analytics'; 2 | import { getClientInstallIdContext } from './contexts'; 3 | import screenTypes from './screenTypes'; 4 | import eventTypes from './eventTypes'; 5 | import { trackTypes, utils } from '@farfetch/blackout-core/analytics'; 6 | export default analyticsNative; 7 | 8 | export { eventTypes, trackTypes, screenTypes, utils }; 9 | 10 | //We export only the contexts that are not already being included by default in analytics 11 | const exportableContexts = { getClientInstallIdContext }; 12 | 13 | export { exportableContexts as contexts }; 14 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/__fixtures__/baseAnalyticsEventData.fixtures.js: -------------------------------------------------------------------------------- 1 | export default { 2 | type: null, 3 | properties: {}, 4 | event: null, 5 | user: { 6 | id: 680968743, 7 | localId: 'd9864a1c112d-47ff-8ee4-968c-5acecae23', 8 | traits: { 9 | name: 'foo bar', 10 | email: 'bar@foo.com', 11 | isGuest: false, 12 | bagId: '1ff36cd1-0dac-497f-8f32-4f2f7bdd2eaf', 13 | gender: 1, 14 | dateOfBirth: '1/1/1970', 15 | createdDate: '1/1/1970', 16 | firstName: 'foo', 17 | lastName: 'bar', 18 | phoneNumber: '+351-99999999', 19 | username: 'foo.bar', 20 | }, 21 | }, 22 | consent: { 23 | marketing: true, 24 | preferences: true, 25 | statistics: true, 26 | }, 27 | context: { 28 | app: { clientInstallId: '7c9d09a8-0b32-4293-9817-e0b17f8830db' }, 29 | library: { 30 | version: '0.1.0', 31 | name: '@farfetch/blackout-core/analytics', 32 | }, 33 | culture: 'en-US', 34 | tenantId: 26000, 35 | clientId: 26000, 36 | device: 'iPhone13,2', 37 | deviceLanguage: 'en', 38 | deviceOS: 'iOS 14.3', 39 | screenHeight: 844, 40 | screenWidth: 390, 41 | }, 42 | timestamp: 1567010265879, 43 | platform: 'mobile', 44 | }; 45 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/__fixtures__/eventSamples.fixtures.js: -------------------------------------------------------------------------------- 1 | import { 2 | fromParameterTypes, 3 | interactionTypes, 4 | trackTypes, 5 | utils, 6 | } from '@farfetch/blackout-core/analytics'; 7 | import eventTypes from '../../eventTypes'; 8 | import screenTypes from '../../screenTypes'; 9 | import baseAnalyticsEventData from './baseAnalyticsEventData.fixtures'; 10 | 11 | const baseTrackData = { 12 | ...baseAnalyticsEventData, 13 | type: trackTypes.TRACK, 14 | }; 15 | 16 | const baseScreenData = { 17 | ...baseAnalyticsEventData, 18 | type: trackTypes.SCREEN, 19 | }; 20 | 21 | export const onSetUserEventData = { 22 | ...baseAnalyticsEventData, 23 | type: utils.ON_SET_USER_TRACK_TYPE, 24 | properties: {}, 25 | event: utils.ON_SET_USER_TRACK_TYPE, 26 | }; 27 | 28 | const eventSamples = { 29 | [eventTypes.APP_OPENED]: { 30 | ...baseTrackData, 31 | event: eventTypes.APP_OPENED, 32 | }, 33 | 34 | [eventTypes.PRODUCT_ADDED_TO_CART]: { 35 | ...baseTrackData, 36 | event: eventTypes.PRODUCT_ADDED_TO_CART, 37 | properties: { 38 | from: fromParameterTypes.WISHLIST, 39 | cartId: 'skdjsidjsdkdj29j', 40 | id: '507f1f77bcf86cd799439011', 41 | sku: 'G-32', 42 | category: 'Clothing/Tops/T-shirts', 43 | name: 'Gareth McConnell Dreamscape T-Shirt', 44 | brand: 'Just A T-Shirt', 45 | variant: 'Black', 46 | size: 'L', 47 | discountValue: 6, 48 | price: 19, 49 | priceWithoutDiscount: 25, 50 | quantity: 1, 51 | currency: 'USD', 52 | list: 'my_wishlist', 53 | listId: 'd3618128-5aa9-4caa-a452-1dd1377a6190', 54 | }, 55 | }, 56 | 57 | [eventTypes.PRODUCT_REMOVED_FROM_CART]: { 58 | ...baseTrackData, 59 | event: eventTypes.PRODUCT_REMOVED_FROM_CART, 60 | properties: { 61 | from: fromParameterTypes.BAG, 62 | cartId: 'ksjdj92dj29dj92d2j', 63 | id: '507f1f77bcf86cd799439011', 64 | sku: 'G-32', 65 | category: 'Clothing/Tops/T-shirts', 66 | name: 'Gareth McConnell Dreamscape T-Shirt', 67 | list: 'Bag', 68 | listId: 'e0030b3c-b970-4496-bc72-f9a38d6270b1', 69 | brand: 'Just A T-Shirt', 70 | variant: 'Black', 71 | size: 'L', 72 | price: 19, 73 | priceWithoutDiscount: 25, 74 | discountValue: 6, 75 | quantity: 1, 76 | currency: 'USD', 77 | value: 19, 78 | position: 1, 79 | }, 80 | }, 81 | 82 | [eventTypes.PAYMENT_INFO_ADDED]: { 83 | ...baseTrackData, 84 | event: eventTypes.PAYMENT_INFO_ADDED, 85 | properties: { 86 | orderId: '50314b8e9bcf000000000000', 87 | total: 24.64, 88 | shipping: 3.6, 89 | tax: 2.04, 90 | coupon: 'ACME2019', 91 | paymentType: 'credit card', 92 | currency: 'USD', 93 | products: [ 94 | { 95 | id: '507f1f77bcf86cd799439011', 96 | category: 'Clothing/Tops/T-shirts/', 97 | name: 'Gareth McConnell Dreamscape T-Shirt', 98 | brand: 'Just A T-Shirt', 99 | variant: 'Black', 100 | currency: 'USD', 101 | size: 'L', 102 | discountValue: 6, 103 | price: 19, 104 | priceWithoutDiscount: 25, 105 | quantity: 1, 106 | }, 107 | ], 108 | }, 109 | }, 110 | 111 | [eventTypes.PRODUCT_ADDED_TO_WISHLIST]: { 112 | ...baseTrackData, 113 | event: eventTypes.PRODUCT_ADDED_TO_WISHLIST, 114 | properties: { 115 | from: fromParameterTypes.PLP, 116 | id: '507f1f77bcf86cd799439011', 117 | category: 'Clothing/Tops/T-shirts', 118 | name: 'Gareth McConnell Dreamscape T-Shirt', 119 | brand: 'Just A T-Shirt', 120 | variant: 'Black', 121 | discountValue: 6, 122 | price: 19, 123 | priceWithoutDiscount: 25, 124 | currency: 'USD', 125 | list: 'Woman shopping', 126 | listId: '/en-pt/shopping/woman', 127 | wishlistId: 'd3618128-5aa9-4caa-a452-1dd1377a6190', 128 | }, 129 | }, 130 | 131 | [eventTypes.PRODUCT_REMOVED_FROM_WISHLIST]: { 132 | ...baseTrackData, 133 | event: eventTypes.PRODUCT_REMOVED_FROM_WISHLIST, 134 | properties: { 135 | from: fromParameterTypes.PLP, 136 | id: '507f1f77bcf86cd799439011', 137 | list: 'Woman shopping', 138 | listId: '/en-pt/shopping/woman', 139 | category: 'Clothing/Tops/T-shirts', 140 | name: 'Gareth McConnell Dreamscape T-Shirt', 141 | brand: 'Just A T-Shirt', 142 | variant: 'Black', 143 | discountValue: 6, 144 | price: 19, 145 | priceWithoutDiscount: 25, 146 | currency: 'USD', 147 | wishlistId: 'd3618128-5aa9-4caa-a452-1dd1377a6190', 148 | }, 149 | }, 150 | 151 | [eventTypes.SHIPPING_INFO_ADDED]: { 152 | ...baseTrackData, 153 | event: eventTypes.SHIPPING_INFO_ADDED, 154 | properties: { 155 | orderId: '50314b8e9bcf000000000000', 156 | total: 24.64, 157 | shipping: 3.6, 158 | tax: 2.04, 159 | coupon: 'ACME2019', 160 | shippingTier: 'Next Day', 161 | currency: 'USD', 162 | products: [ 163 | { 164 | id: '507f1f77bcf86cd799439011', 165 | category: 'Clothing/Tops/T-shirts/', 166 | name: 'Gareth McConnell Dreamscape T-Shirt', 167 | brand: 'Just A T-Shirt', 168 | variant: 'Black', 169 | currency: 'USD', 170 | size: 'L', 171 | discountValue: 6, 172 | price: 19, 173 | priceWithoutDiscount: 25, 174 | quantity: 1, 175 | }, 176 | ], 177 | }, 178 | }, 179 | 180 | [eventTypes.CHECKOUT_STARTED]: { 181 | ...baseTrackData, 182 | event: eventTypes.CHECKOUT_STARTED, 183 | properties: { 184 | orderId: '50314b8e9bcf000000000000', 185 | total: 24.64, 186 | shipping: 3.6, 187 | tax: 2.04, 188 | coupon: 'ACME2019', 189 | currency: 'USD', 190 | products: [ 191 | { 192 | id: '507f1f77bcf86cd799439011', 193 | category: 'Clothing/Tops/T-shirts/', 194 | name: 'Gareth McConnell Dreamscape T-Shirt', 195 | brand: 'Just A T-Shirt', 196 | currency: 'USD', 197 | variant: 'Black', 198 | size: 'L', 199 | discountValue: 6, 200 | price: 19, 201 | priceWithoutDiscount: 25, 202 | quantity: 1, 203 | }, 204 | ], 205 | }, 206 | }, 207 | 208 | [eventTypes.ORDER_COMPLETED]: { 209 | ...baseTrackData, 210 | event: eventTypes.ORDER_COMPLETED, 211 | properties: { 212 | orderId: '50314b8e9bcf000000000000', 213 | total: 24.64, 214 | shipping: 3.6, 215 | tax: 2.04, 216 | coupon: 'ACME2019', 217 | currency: 'USD', 218 | products: [ 219 | { 220 | id: '507f1f77bcf86cd799439011', 221 | category: 'Clothing/Tops/T-shirts/', 222 | name: 'Gareth McConnell Dreamscape T-Shirt', 223 | brand: 'Just A T-Shirt', 224 | currency: 'USD', 225 | variant: 'Black', 226 | size: 'L', 227 | discountValue: 6, 228 | price: 19, 229 | priceWithoutDiscount: 25, 230 | quantity: 1, 231 | }, 232 | ], 233 | }, 234 | }, 235 | 236 | [eventTypes.ORDER_REFUNDED]: { 237 | ...baseTrackData, 238 | event: eventTypes.ORDER_REFUNDED, 239 | properties: { 240 | orderId: '50314b8e9bcf000000000000', 241 | total: 19, 242 | currency: 'USD', 243 | products: [ 244 | { 245 | id: '507f1f77bcf86cd799439011', 246 | category: 'Clothing/Tops/T-shirts/', 247 | name: 'Gareth McConnell Dreamscape T-Shirt', 248 | brand: 'Just A T-Shirt', 249 | currency: 'USD', 250 | variant: 'Black', 251 | size: 'L', 252 | discountValue: 6, 253 | price: 19, 254 | priceWithoutDiscount: 25, 255 | quantity: 1, 256 | }, 257 | ], 258 | }, 259 | }, 260 | 261 | [eventTypes.SELECT_CONTENT]: { 262 | ...baseTrackData, 263 | event: eventTypes.SELECT_CONTENT, 264 | properties: { 265 | contentType: 'biz', 266 | id: 12312312, 267 | }, 268 | }, 269 | 270 | [eventTypes.PRODUCT_CLICKED]: { 271 | ...baseTrackData, 272 | event: eventTypes.PRODUCT_CLICKED, 273 | properties: { 274 | from: fromParameterTypes.PLP, 275 | id: '507f1f77bcf86cd799439011', 276 | name: 'Gareth McConnell Dreamscape T-Shirt', 277 | position: 3, 278 | list: 'Woman shopping', 279 | listId: '/en-pt/shopping/woman', 280 | currency: 'GBP', 281 | discountValue: 6, 282 | price: 19, 283 | priceWithoutDiscount: 25, 284 | }, 285 | }, 286 | 287 | [eventTypes.PRODUCT_VIEWED]: { 288 | ...baseTrackData, 289 | event: eventTypes.PRODUCT_VIEWED, 290 | properties: { 291 | from: fromParameterTypes.PLP, 292 | id: '507f1f77bcf86cd799439011', 293 | sku: 'G-32', 294 | category: 'Clothing/Tops/T-shirts', 295 | name: 'Gareth McConnell Dreamscape T-Shirt', 296 | brand: 'Just A T-Shirt', 297 | variant: 'Black', 298 | list: 'Woman shopping', 299 | listId: '/en-pt/shopping/woman', 300 | discountValue: 6, 301 | price: 19, 302 | priceWithoutDiscount: 25, 303 | currency: 'USD', 304 | isOutOfStock: true, 305 | }, 306 | }, 307 | 308 | [eventTypes.PRODUCT_LIST_VIEWED]: { 309 | ...baseTrackData, 310 | event: eventTypes.PRODUCT_LIST_VIEWED, 311 | properties: { 312 | from: fromParameterTypes.PLP, 313 | category: 'Clothing', 314 | list: 'Woman shopping', 315 | currency: 'USD', 316 | products: [ 317 | { 318 | id: '507f1f77bcf86cd799439011', 319 | name: 'Gareth McConnell Dreamscape T-Shirt', 320 | position: 2, 321 | currency: 'USD', 322 | discountValue: 6, 323 | price: 19, 324 | priceWithoutDiscount: 25, 325 | list: 'Woman shopping', 326 | listId: '09a35590-bb62-4027-a630-5da04ec64fb5', 327 | }, 328 | { 329 | id: '507f1f77bcf86cd799439012', 330 | name: 'Gareth McConnell Dreamscape T-Shirt', 331 | position: 3, 332 | currency: 'USD', 333 | discountValue: 6, 334 | price: 19, 335 | priceWithoutDiscount: 25, 336 | list: 'Woman shopping', 337 | listId: '09a35590-bb62-4027-a630-5da04ec64fb5', 338 | }, 339 | ], 340 | }, 341 | }, 342 | 343 | [eventTypes.LOGIN]: { 344 | ...baseTrackData, 345 | event: eventTypes.LOGIN, 346 | properties: { 347 | method: 'Acme', 348 | }, 349 | }, 350 | 351 | [eventTypes.SIGNUP_FORM_COMPLETED]: { 352 | ...baseTrackData, 353 | event: eventTypes.SIGNUP_FORM_COMPLETED, 354 | properties: { 355 | method: 'Acme', 356 | }, 357 | }, 358 | 359 | [eventTypes.FILTERS_APPLIED]: { 360 | ...baseTrackData, 361 | event: eventTypes.FILTERS_APPLIED, 362 | properties: { 363 | filters: { 364 | brands: [2765, 4062], 365 | categories: [135973], 366 | colors: [1], 367 | discount: [0], 368 | gender: [0], 369 | price: [0, 1950], 370 | sizes: [16], 371 | }, 372 | }, 373 | }, 374 | 375 | [eventTypes.FILTERS_CLEARED]: { 376 | ...baseTrackData, 377 | event: eventTypes.FILTERS_CLEARED, 378 | properties: { 379 | filters: { 380 | brands: [2765, 4062], 381 | categories: [135973], 382 | colors: [1], 383 | discount: [0], 384 | gender: [0], 385 | price: [0, 1950], 386 | sizes: [16], 387 | }, 388 | }, 389 | }, 390 | 391 | [eventTypes.SHARE]: { 392 | ...baseTrackData, 393 | event: eventTypes.SHARE, 394 | properties: { 395 | method: 'Facebook', 396 | contentType: 'image', 397 | id: '123456', 398 | }, 399 | }, 400 | 401 | [eventTypes.CHECKOUT_ABANDONED]: { 402 | ...baseTrackData, 403 | event: eventTypes.CHECKOUT_ABANDONED, 404 | properties: { 405 | orderId: '50314b8e9bcf000000000000', 406 | total: 24.64, 407 | shipping: 3.6, 408 | tax: 2.04, 409 | coupon: 'ACME2019', 410 | currency: 'USD', 411 | }, 412 | }, 413 | 414 | [eventTypes.PLACE_ORDER_STARTED]: { 415 | ...baseTrackData, 416 | event: eventTypes.PLACE_ORDER_STARTED, 417 | properties: { 418 | orderId: '50314b8e9bcf000000000000', 419 | total: 24.64, 420 | shipping: 3.6, 421 | tax: 2.04, 422 | coupon: 'ACME2019', 423 | currency: 'USD', 424 | }, 425 | }, 426 | 427 | [eventTypes.PROMOCODE_APPLIED]: { 428 | ...baseTrackData, 429 | event: eventTypes.PROMOCODE_APPLIED, 430 | properties: { 431 | orderId: '50314b8e9bcf000000000000', 432 | total: 24.64, 433 | shipping: 3.6, 434 | tax: 2.04, 435 | coupon: 'ACME2019', 436 | shippingTier: 'Next Day', 437 | currency: 'USD', 438 | }, 439 | }, 440 | 441 | [eventTypes.CHECKOUT_STEP_EDITING]: { 442 | ...baseTrackData, 443 | event: eventTypes.CHECKOUT_STEP_EDITING, 444 | properties: { 445 | step: 1, 446 | }, 447 | }, 448 | 449 | [eventTypes.ADDRESS_INFO_ADDED]: { 450 | ...baseTrackData, 451 | event: eventTypes.ADDRESS_INFO_ADDED, 452 | properties: { 453 | orderId: '50314b8e9bcf000000000000', 454 | total: 24.64, 455 | shipping: 3.6, 456 | tax: 2.04, 457 | coupon: 'ACME2019', 458 | shippingTier: 'Next Day', 459 | currency: 'USD', 460 | }, 461 | }, 462 | 463 | [eventTypes.SHIPPING_METHOD_ADDED]: { 464 | ...baseTrackData, 465 | event: eventTypes.SHIPPING_METHOD_ADDED, 466 | properties: { 467 | orderId: '50314b8e9bcf000000000000', 468 | total: 24.64, 469 | shipping: 3.6, 470 | tax: 2.04, 471 | coupon: 'ACME2019', 472 | shippingTier: 'Next Day', 473 | currency: 'USD', 474 | }, 475 | }, 476 | 477 | [eventTypes.INTERACT_CONTENT]: { 478 | ...baseTrackData, 479 | event: eventTypes.INTERACT_CONTENT, 480 | properties: { 481 | interactionType: interactionTypes.CLICK, 482 | contentType: 'biz', 483 | someOtherProperty: 12312312, 484 | }, 485 | }, 486 | 487 | [eventTypes.SIGNUP_NEWSLETTER]: { 488 | ...baseTrackData, 489 | event: eventTypes.SIGNUP_NEWSLETTER, 490 | properties: { 491 | gender: '0', 492 | }, 493 | }, 494 | 495 | [eventTypes.PRODUCT_UPDATED]: { 496 | ...baseTrackData, 497 | event: eventTypes.PRODUCT_UPDATED, 498 | properties: { 499 | from: fromParameterTypes.BAG, 500 | id: '507f1f77bcf86cd799439011', 501 | name: 'Gareth McConnell Dreamscape T-Shirt', 502 | colour: 'red', 503 | oldColour: undefined, 504 | size: 'L', 505 | oldSize: undefined, 506 | quantity: 1, 507 | oldQuantity: undefined, 508 | }, 509 | }, 510 | 511 | [screenTypes.SEARCH]: { 512 | ...baseScreenData, 513 | properties: { 514 | searchQuery: 'shoes', 515 | currency: 'EUR', 516 | products: [{ id: 10000 }, { id: 20000 }], 517 | }, 518 | event: screenTypes.SEARCH, 519 | }, 520 | 521 | [screenTypes.BAG]: { 522 | ...baseScreenData, 523 | event: screenTypes.BAG, 524 | properties: { 525 | currency: 'USD', 526 | from: fromParameterTypes.BAG, 527 | list: 'Bag', 528 | listId: 'e0030b3c-b970-4496-bc72-f9a38d6270b1', 529 | products: [ 530 | { 531 | id: '507f1f77bcf86cd799439011', 532 | category: 'Clothing/Tops/T-shirts/', 533 | name: 'Gareth McConnell Dreamscape T-Shirt', 534 | brand: 'Just A T-Shirt', 535 | variant: 'Black', 536 | size: 'L', 537 | discountValue: 6, 538 | price: 19, 539 | priceWithoutDiscount: 25, 540 | quantity: 1, 541 | }, 542 | ], 543 | }, 544 | }, 545 | 546 | [screenTypes.WISHLIST]: { 547 | ...baseScreenData, 548 | event: screenTypes.WISHLIST, 549 | properties: { 550 | currency: 'USD', 551 | from: fromParameterTypes.WISHLIST, 552 | wishlistId: 'd3618128-5aa9-4caa-a452-1dd1377a6190', 553 | }, 554 | }, 555 | }; 556 | 557 | export default eventSamples; 558 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/__fixtures__/generateAnalyticsEventData.fixtures.js: -------------------------------------------------------------------------------- 1 | import { 2 | trackTypes as analyticsTrackTypes, 3 | utils, 4 | } from '@farfetch/blackout-core/analytics'; 5 | 6 | export default function generateAnalyticsEventData( 7 | trackType = analyticsTrackTypes.TRACK, 8 | event, 9 | properties = {}, 10 | ) { 11 | return { 12 | consent: { marketing: true, preferences: true, statistics: true }, 13 | context: { 14 | app: { clientInstallId: '7c9d09a8-0b32-4293-9817-e0b17f8830db' }, 15 | clientId: 16000, 16 | device: 'iPhone13,2', 17 | deviceLanguage: 'en', 18 | deviceOS: 'iOS 14.3', 19 | event: { 20 | [utils.ANALYTICS_UNIQUE_EVENT_ID]: 21 | '179373c4-5651-40fe-8a50-66c3d7c86912', 22 | }, 23 | library: { 24 | version: '1.15.0-chore-FPSCH-625-add-support-for-site-features.0', 25 | name: '@farfetch/blackout-core/analytics', 26 | }, 27 | screenHeight: 844, 28 | screenWidth: 390, 29 | tenantId: 16000, 30 | }, 31 | event, 32 | platform: 'mobile', 33 | properties, 34 | timestamp: 1610532249124, 35 | type: trackType, 36 | user: { 37 | id: 5000003260042599, 38 | localId: '9c48bea6-53fa-483a-b0a7-50408c2c1e4e', 39 | traits: { isGuest: false, name: 'John Doe', email: 'john@email.com' }, 40 | }, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/castle/Castle.js: -------------------------------------------------------------------------------- 1 | import CastleReactNative from '@castleio/react-native-castle'; 2 | import utils from '@farfetch/blackout-core/analytics/utils'; 3 | import { trackTypes } from '@farfetch/blackout-core/analytics'; 4 | import { Integration } from '@farfetch/blackout-core/analytics/integrations'; 5 | import coreClient from '@farfetch/blackout-core/helpers/client'; 6 | 7 | export const LOGGER_MESSAGE_PREFIX = 'Castle:'; 8 | export const HTTP_CLIENT_ERROR_MESSAGE = `${LOGGER_MESSAGE_PREFIX} Make sure you are passing your HTTP client with the correct header. We strongly advise to use an interceptor to correctly set the Castle.io header as it should be treated as a token instead of a static value.`; 9 | 10 | class Castle extends Integration { 11 | /** 12 | * Method to check if the integration is ready to be loaded. 13 | * Since this is a mandatory integration, it will always return true and will not depend on user consent. 14 | * @static 15 | * 16 | * @returns {Boolean} - If the integration is ready to be loaded. 17 | */ 18 | static shouldLoad() { 19 | return true; 20 | } 21 | 22 | /** 23 | * Method used to create a new Castle instance by analytics. 24 | * 25 | * @static 26 | * 27 | * @param {object} options - Integration options. 28 | * @param {object} loadData - analytics' load event data. 29 | * @param {object} strippedAnalytics - analytics' stripped instance methods. 30 | * 31 | * @returns {object} - An instance of Castle class. 32 | */ 33 | static createInstance(options, loadData, strippedAnalytics) { 34 | return new Castle(options, loadData, strippedAnalytics); 35 | } 36 | 37 | /** 38 | * Creates an instance of Castle. 39 | * 40 | * @param {object} options - User configured options. 41 | * @param {object} loadData - analytics' load event data. 42 | * @param {object} strippedAnalytics - analytics' stripped instance methods. 43 | */ 44 | constructor(options, loadData, strippedAnalytics) { 45 | super(options, loadData, strippedAnalytics); 46 | 47 | // This will allow users to have access to the Castle.io module and perform any project-specific operations with it, 48 | // by accessing the integration instance via `analytics.integration('castle').castleIO;` 49 | this.castleIO = CastleReactNative; 50 | 51 | this.lastUserId = null; 52 | this.isInterceptorAttached = false; 53 | this.httpClientInterceptor = null; 54 | this.httpClient = options?.httpClient || coreClient; 55 | 56 | this.initializePromiseResolve = null; 57 | this.initializePromise = new Promise(initializePromiseResolve => { 58 | this.initializePromiseResolve = initializePromiseResolve; 59 | }); 60 | 61 | this.initialize(options); 62 | } 63 | 64 | /** 65 | * Initialization method that will configure the Castle instance with the options provided to the integration. 66 | * Calls the configureHttpClient to configure the HTTP client to be used on the project that will be profiled by this integration. 67 | * 68 | * @async 69 | * 70 | * @param {object} options - User configured options. 71 | */ 72 | async initialize(options) { 73 | await this.configureHttpClient(options); 74 | 75 | try { 76 | await this.castleIO.configure(options?.configureOptions).then(() => { 77 | if (this.initializePromiseResolve) { 78 | this.initializePromiseResolve(); 79 | this.initializePromiseResolve = null; 80 | } 81 | }); 82 | } catch (error) { 83 | utils.logger.error( 84 | `${LOGGER_MESSAGE_PREFIX} Failed to initialize the Castle.io SDK. ${error}`, 85 | ); 86 | } 87 | } 88 | 89 | /** 90 | * Method responsible for setting the correct clientId header to be sent to our services. 91 | * If passed a custom function to do this job, call it instead of performing the default operation to our core client. 92 | * 93 | * @param {object} options - User configured options. 94 | * 95 | * @async 96 | */ 97 | async configureHttpClient(options) { 98 | // Custom configuration 99 | const configureHttpClientCustomFn = options?.configureHttpClient; 100 | 101 | if ( 102 | configureHttpClientCustomFn && 103 | typeof configureHttpClientCustomFn !== 'function' 104 | ) { 105 | utils.logger.error( 106 | `${LOGGER_MESSAGE_PREFIX} TypeError: "configureHttpClient" is not a function. Make sure you are passing a valid function via the integration's options.`, 107 | ); 108 | 109 | return; 110 | } 111 | 112 | if (configureHttpClientCustomFn) { 113 | try { 114 | await configureHttpClientCustomFn(this.castleIO); 115 | 116 | this.isInterceptorAttached = true; 117 | } catch (error) { 118 | utils.logger.error( 119 | `${LOGGER_MESSAGE_PREFIX} There was an error trying to execute the "configureHttpClient" custom function. ${error}`, 120 | ); 121 | 122 | this.isInterceptorAttached = false; 123 | } 124 | 125 | return; 126 | } 127 | 128 | // Default configuration of our @farfetch/blackout-core client (axios) using an interceptor. 129 | // Store the interceptor on the instance in case the user wants to remove it. 130 | this.httpClientInterceptor = this.httpClient?.interceptors?.request?.use( 131 | this.onBeforeRequestFullfil, 132 | null, 133 | ); 134 | } 135 | 136 | /** 137 | * Method that will enable screen tracking. 138 | * 139 | * @param {object} data - Event data provided by analytics. 140 | * 141 | * @async 142 | */ 143 | async track(data) { 144 | if (data.type === trackTypes.SCREEN) { 145 | await this.castleIO.screen(data.event); 146 | } 147 | } 148 | 149 | /** 150 | * Callback that is used on the Axios interceptor to add the correct Castle.io token header. 151 | * 152 | * @async 153 | * 154 | * @param {object} config - Axios config object. 155 | * 156 | * @returns {Promise} - The modified Axios config object. 157 | */ 158 | onBeforeRequestFullfil = async config => { 159 | await this.initializePromise; 160 | 161 | let headerName = ''; 162 | let headerValue = ''; 163 | 164 | // @TODO: remove this option when we no longer support the clientId header on our backend. 165 | if (this.options?.useLegacyHeader) { 166 | headerName = await this.castleIO.clientIdHeaderName(); 167 | headerValue = await this.castleIO.clientId(); 168 | } else { 169 | headerName = await this.castleIO.requestTokenHeaderName(); 170 | headerValue = await this.castleIO.createRequestToken(); 171 | } 172 | 173 | config.headers[headerName] = headerValue; 174 | 175 | this.isInterceptorAttached = true; 176 | 177 | return config; 178 | }; 179 | 180 | /** 181 | * Handles when the onSetUser is called on the analytics side. Logs when a login occurs and sets both the ID and user traits. 182 | * 183 | * @async 184 | * 185 | * @param {object} data - Event data provided by analytics. 186 | */ 187 | async onSetUser(data) { 188 | await this.initializePromise; 189 | 190 | if (!this.httpClient || !this.isInterceptorAttached) { 191 | utils.logger.error(HTTP_CLIENT_ERROR_MESSAGE); 192 | 193 | return; 194 | } 195 | 196 | const userData = data?.user || {}; 197 | const userId = userData.id; 198 | const traits = userData.traits; 199 | const isGuest = traits?.isGuest; 200 | 201 | // If for some reason there was a call to `analytics.onSetUser()` that receives the same user id, ignore it. 202 | if (userId === this.lastUserId) { 203 | return; 204 | } 205 | 206 | try { 207 | // Login - Let Castle identify the user. 208 | if (userId && !isGuest) { 209 | await this.castleIO.identify(userId, traits); 210 | await this.castleIO.secure(userId); 211 | } else { 212 | // Logout 213 | await this.castleIO.reset(); 214 | } 215 | } catch (error) { 216 | utils.logger.error( 217 | `${LOGGER_MESSAGE_PREFIX} Failed to track the user login/logout with the Castle.io SDK. ${error}`, 218 | ); 219 | } 220 | 221 | this.lastUserId = userId; 222 | } 223 | } 224 | 225 | export default Castle; 226 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/castle/__tests__/Castle.test.js: -------------------------------------------------------------------------------- 1 | import CastleReactNativeIntegration, { 2 | LOGGER_MESSAGE_PREFIX, 3 | HTTP_CLIENT_ERROR_MESSAGE, 4 | } from '../'; 5 | import Integration from '../../integration'; 6 | import CastleIO from '@castleio/react-native-castle'; 7 | import coreClient from '@farfetch/blackout-core/helpers/client'; 8 | import utils from '@farfetch/blackout-core/analytics/utils'; 9 | 10 | const mockRequestHeaderName = 'castle-header-foo'; 11 | const mockRequestHeaderValue = '12342342345241342423424'; 12 | jest.mock('@castleio/react-native-castle', () => { 13 | return { 14 | ...jest.requireActual('@castleio/react-native-castle'), 15 | configure: jest.fn(() => Promise.resolve()), 16 | requestTokenHeaderName: async () => Promise.resolve(mockRequestHeaderName), 17 | createRequestToken: async () => Promise.resolve(mockRequestHeaderValue), 18 | identify: jest.fn(() => Promise.resolve()), 19 | reset: jest.fn(() => Promise.resolve()), 20 | }; 21 | }); 22 | 23 | jest.mock('@farfetch/blackout-core/analytics/utils', () => ({ 24 | ...jest.requireActual('@farfetch/blackout-core/analytics/utils'), 25 | logger: { 26 | error: jest.fn(), 27 | warn: jest.fn(), 28 | }, 29 | })); 30 | 31 | const mockIntegrationOptions = { 32 | httpClient: { 33 | defaults: { 34 | headers: { 35 | common: {}, 36 | }, 37 | }, 38 | }, 39 | configureHttpClient: jest.fn(), 40 | }; 41 | const getIntegrationInstance = async customOptions => { 42 | const instance = CastleReactNativeIntegration.createInstance( 43 | customOptions || mockIntegrationOptions, 44 | ); 45 | 46 | await instance.initialize(); 47 | 48 | return instance; 49 | }; 50 | 51 | describe('Castle', () => { 52 | beforeEach(() => { 53 | jest.clearAllMocks(); 54 | }); 55 | 56 | describe('Integration', () => { 57 | it('should extend the default Integration class', async () => { 58 | const instance = await getIntegrationInstance(); 59 | 60 | expect(instance).toBeInstanceOf(CastleReactNativeIntegration); 61 | expect(instance).toBeInstanceOf(Integration); 62 | }); 63 | 64 | it('should not depend on the consent to be loaded (required)', () => { 65 | expect(CastleReactNativeIntegration.shouldLoad()).toBe(true); 66 | expect(CastleReactNativeIntegration.shouldLoad(null)).toBe(true); 67 | expect( 68 | CastleReactNativeIntegration.shouldLoad({ 69 | statistics: false, 70 | marketing: false, 71 | preferences: false, 72 | }), 73 | ).toBe(true); 74 | }); 75 | }); 76 | 77 | describe('Instance', () => { 78 | it('should have a property referring the Castle.io instance', async () => { 79 | const instance = await getIntegrationInstance(); 80 | 81 | expect(instance.castleIO).toBe(CastleIO); 82 | }); 83 | 84 | describe('HTTP client', () => { 85 | it('should have the default one assigned if none passed', async () => { 86 | const instance = CastleReactNativeIntegration.createInstance({}); 87 | 88 | await instance.initialize(); 89 | 90 | expect(instance.httpClient).toEqual(coreClient); 91 | }); 92 | 93 | it('should allow to pass a custom "httpClient" and "configureHttpClient" function', async () => { 94 | const instance = await getIntegrationInstance(); // already called with custom options, no need to create custom ones 95 | 96 | expect(mockIntegrationOptions.configureHttpClient).toHaveBeenCalledWith( 97 | instance.castleIO, 98 | ); 99 | }); 100 | 101 | it('should log an error if a invalid "configureHttpClient" option is passed', async () => { 102 | await getIntegrationInstance({ 103 | configureHttpClient: 'foo', 104 | }); 105 | 106 | expect(utils.logger.error).toHaveBeenCalledWith( 107 | `${LOGGER_MESSAGE_PREFIX} TypeError: "configureHttpClient" is not a function. Make sure you are passing a valid function via the integration's options.`, 108 | ); 109 | }); 110 | 111 | it('should log an error if an error occurs on "configureHttpClient" custom function', async () => { 112 | const error = 'this is an error'; 113 | await getIntegrationInstance({ 114 | configureHttpClient: () => { 115 | throw new Error(error); 116 | }, 117 | }); 118 | 119 | expect(utils.logger.error).toHaveBeenCalledWith( 120 | `${LOGGER_MESSAGE_PREFIX} There was an error trying to execute the "configureHttpClient" custom function. Error: ${error}`, 121 | ); 122 | }); 123 | 124 | it('should set the correct header with the correct name to the HTTP client (axios interceptor fullfil callback)', async () => { 125 | const instance = await getIntegrationInstance({}); 126 | 127 | expect(instance.httpClientInterceptor).toBeDefined(); 128 | 129 | const config = await instance.onBeforeRequestFullfil({ headers: {} }); 130 | 131 | expect(config).toEqual({ 132 | headers: { 133 | [mockRequestHeaderName]: mockRequestHeaderValue, 134 | }, 135 | }); 136 | 137 | expect(instance.isInterceptorAttached).toBe(true); 138 | }); 139 | }); 140 | 141 | describe('Initialization', () => { 142 | it('should call the .configure method of castle with the provided options', async () => { 143 | const castleOptions = { 144 | configureOptions: { 145 | publishableKey: '123123', 146 | debugLoggingEnabled: true, 147 | flushLimit: 1, 148 | maxQueueLimit: 1, 149 | baseURLAllowList: [], 150 | }, 151 | }; 152 | const instance = await getIntegrationInstance({ 153 | ...mockIntegrationOptions, 154 | ...castleOptions, 155 | }); 156 | 157 | expect(instance.castleIO.configure).toHaveBeenCalledWith( 158 | castleOptions.configureOptions, 159 | ); 160 | }); 161 | 162 | it('should handle any errors that may occur when trying to initialize the SDK', async () => { 163 | const errorMessage = 'this is an error'; 164 | 165 | CastleIO.configure.mockImplementationOnce(() => { 166 | throw new Error(errorMessage); 167 | }); 168 | 169 | await getIntegrationInstance(); 170 | 171 | expect(utils.logger.error).toHaveBeenCalledWith( 172 | `${LOGGER_MESSAGE_PREFIX} Failed to initialize the Castle.io SDK. Error: ${errorMessage}`, 173 | ); 174 | }); 175 | }); 176 | 177 | describe('OnSetUser', () => { 178 | it('should log an error if there is no httpClient setted nor the interceptor attached', async () => { 179 | const instance = await getIntegrationInstance(); 180 | instance.httpClient = null; 181 | 182 | await instance.onSetUser(); 183 | 184 | expect(utils.logger.error).toHaveBeenCalledWith( 185 | HTTP_CLIENT_ERROR_MESSAGE, 186 | ); 187 | 188 | utils.logger.error.mockClear(); 189 | 190 | instance.isInterceptorAttached = false; 191 | 192 | await instance.onSetUser(); 193 | 194 | expect(utils.logger.error).toHaveBeenCalledWith( 195 | HTTP_CLIENT_ERROR_MESSAGE, 196 | ); 197 | }); 198 | 199 | it('should not call the .identify method if the userId is the same as the last one', async () => { 200 | const instance = await getIntegrationInstance(); 201 | const userId = 123123; 202 | const traits = { 203 | email: 'foo@bar.com', 204 | }; 205 | 206 | await instance.onSetUser({ 207 | user: { 208 | id: userId, 209 | traits, 210 | }, 211 | }); 212 | 213 | expect(instance.castleIO.identify).toHaveBeenCalledWith(userId, traits); 214 | 215 | instance.castleIO.identify.mockClear(); 216 | 217 | await instance.onSetUser({ 218 | user: { 219 | id: userId, 220 | traits, 221 | }, 222 | }); 223 | 224 | expect(instance.castleIO.identify).not.toHaveBeenCalled(); 225 | }); 226 | 227 | it('should log an error if the castle.io SDK throws an error while trying to identify the user', async () => { 228 | const errorMessage = 'this is an error'; 229 | 230 | CastleIO.identify.mockImplementationOnce(() => { 231 | throw new Error(errorMessage); 232 | }); 233 | 234 | const instance = await getIntegrationInstance(); 235 | 236 | await instance.onSetUser({ 237 | user: { 238 | id: 123, 239 | }, 240 | }); 241 | 242 | expect(utils.logger.error).toHaveBeenCalledWith( 243 | `${LOGGER_MESSAGE_PREFIX} Failed to track the user login/logout with the Castle.io SDK. Error: ${errorMessage}`, 244 | ); 245 | }); 246 | 247 | it('should call .reset method if the user is logging out', async () => { 248 | const instance = await getIntegrationInstance(); 249 | const userId = 1231; 250 | const traits = { 251 | isGuest: true, 252 | }; 253 | 254 | await instance.onSetUser({ 255 | id: userId, 256 | traits, 257 | }); 258 | 259 | expect(instance.castleIO.reset).toHaveBeenCalled(); 260 | }); 261 | }); 262 | }); 263 | }); 264 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/castle/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Castle'; 2 | 3 | export * from './Castle'; 4 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/firebaseAnalytics/FirebaseAnalytics.js: -------------------------------------------------------------------------------- 1 | import { 2 | trackTypes as analyticsTrackTypes, 3 | utils, 4 | } from '@farfetch/blackout-core/analytics'; 5 | import { buildCustomEventsMapper } from './utils'; 6 | import { 7 | MESSAGE_PREFIX, 8 | OPTION_EVENTS_MAPPER, 9 | OPTION_ON_SET_USER_HANDLER, 10 | OPTION_SCREEN_VIEWS_MAPPER, 11 | OPTION_SET_CUSTOM_USER_ID_PROPERTY, 12 | OPTION_GOOGLE_CONSENT_CONFIG, 13 | } from './constants'; 14 | import { 15 | defaultEventsMapper, 16 | defaultScreenViewsMapper, 17 | getVirtualEventsFromEvent, 18 | } from './defaultMappers'; 19 | import get from 'lodash/get'; 20 | import omit from 'lodash/omit'; 21 | import Integration from '../integration'; 22 | import screenTypes from '../../screenTypes'; 23 | 24 | let firebaseAnalytics; 25 | 26 | try { 27 | firebaseAnalytics = require('@react-native-firebase/analytics').default; 28 | } catch (er) { 29 | firebaseAnalytics = null; 30 | } 31 | 32 | function checkRNFirebaseAnalyticsInstalled() { 33 | if (!firebaseAnalytics) { 34 | throw new Error( 35 | `${MESSAGE_PREFIX} "@react-native-firebase/analytics" package is not installed. Please, make sure you have this dependency installed before using this integration.`, 36 | ); 37 | } 38 | } 39 | 40 | /** 41 | * Firebase integration which allows tracking events to Google Analytics 4 properties through Google Analytics for Firebase. 42 | */ 43 | class FirebaseAnalytics extends Integration { 44 | /** 45 | * Creates an instance of FirebaseAnalytics integration. 46 | * Will throw an error if the peer dependency @react-native-firebase/analytics 47 | * is not installed. 48 | * 49 | * @throws 50 | * 51 | * @param {object} options - User configured options. 52 | * @param {object} loadData - Analytics' load event data. 53 | */ 54 | constructor(options = {}, loadData = {}) { 55 | super(options, loadData); 56 | 57 | checkRNFirebaseAnalyticsInstalled(); 58 | 59 | this.initialize(options); 60 | this.onSetUser(loadData, options); 61 | this.googleConsentConfig = options[OPTION_GOOGLE_CONSENT_CONFIG]; 62 | this.googleConsentConfigWithoutMode = omit(this.googleConsentConfig, [ 63 | 'mode', 64 | ]); 65 | 66 | // Call setConsent so that consent mode is persisted 67 | this.setConsent(loadData.consent); 68 | } 69 | 70 | /** 71 | * Method to check if the integration is ready to be loaded. 72 | * If the googleConsentConfig.mode property is set to 'Advanced' 73 | * the integration will be loaded regardless of user consent. 74 | * Else, only if statistics consent is given by the user. 75 | * 76 | * @param consent - User consent data. 77 | * @param options - Options passed for the Firebase integration. 78 | * 79 | * @returns If the integration is ready to be loaded. 80 | */ 81 | static shouldLoad(consent, options) { 82 | if (get(options, `${OPTION_GOOGLE_CONSENT_CONFIG}.mode`) === 'Advanced') { 83 | return true; 84 | } 85 | 86 | return !!consent && !!consent.statistics; 87 | } 88 | 89 | /** 90 | * Method used to create a new FirebaseAnalytics instance by analytics. 91 | * 92 | * @static 93 | * 94 | * @param {object} options - Integration options. 95 | * @param {object} loadData - Analytics' load event data. 96 | * 97 | * @returns {object} - An instance of FirebaseAnalytics class. 98 | */ 99 | static createInstance(options, loadData) { 100 | return new FirebaseAnalytics(options, loadData); 101 | } 102 | 103 | /** 104 | * Initializes the integration. 105 | * 106 | * @param {object} options - Options object passed to the integration by analytics. 107 | */ 108 | initialize(options) { 109 | this.customEventsMapper = buildCustomEventsMapper( 110 | options, 111 | OPTION_EVENTS_MAPPER, 112 | ); 113 | 114 | this.customScreenViewsMapper = buildCustomEventsMapper( 115 | options, 116 | OPTION_SCREEN_VIEWS_MAPPER, 117 | ); 118 | 119 | this.setCustomUserIdProperty = get( 120 | options, 121 | OPTION_SET_CUSTOM_USER_ID_PROPERTY, 122 | true, 123 | ); 124 | } 125 | 126 | /** 127 | * Handles when the onSetUser is called on the analytics side. 128 | * By default, it will set the user id and "is_guest" and "crm_id" user properties. 129 | * If a custom 'onSetUser' handler is specified by the user, it will be called after 130 | * the default user properties are set by this method, which allows the user to override them 131 | * if it is necessary to. 132 | * 133 | * @async 134 | * @param {object} data - Event data provided by analytics. 135 | * 136 | * @returns {Promise} - Promise that will be resolved when the method finishes. 137 | */ 138 | async onSetUser(data) { 139 | try { 140 | const userId = get(data, 'user.id', null); 141 | const isGuest = get(data, 'user.traits.isGuest', true); 142 | 143 | const firebaseAnalyticsInstance = firebaseAnalytics(); 144 | 145 | const customOnSetUser = get(this.options, OPTION_ON_SET_USER_HANDLER); 146 | 147 | if (customOnSetUser && typeof customOnSetUser !== 'function') { 148 | utils.logger.error( 149 | `${MESSAGE_PREFIX} TypeError: "${OPTION_ON_SET_USER_HANDLER}" is not a function. If you are passing a custom "${OPTION_ON_SET_USER_HANDLER}" property to the integration, make sure you are passing a valid function.`, 150 | ); 151 | 152 | return this; 153 | } 154 | 155 | if (customOnSetUser) { 156 | await customOnSetUser(data, firebaseAnalyticsInstance); 157 | 158 | return this; 159 | } 160 | 161 | await firebaseAnalyticsInstance.setUserId( 162 | isGuest ? null : userId.toString(), 163 | ); 164 | await firebaseAnalyticsInstance.setUserProperties({ 165 | is_guest: isGuest.toString(), 166 | crm_id: 167 | isGuest || !this.setCustomUserIdProperty ? null : userId.toString(), 168 | }); 169 | } catch (error) { 170 | utils.logger.error( 171 | `${MESSAGE_PREFIX} An error occurred when trying to process a user changed event: ${error}`, 172 | ); 173 | } 174 | 175 | return this; 176 | } 177 | 178 | /** 179 | * Extension of the track method to handle both screen and event trackings. 180 | * 181 | * @async 182 | * 183 | * @param {object} data - Event data provided by analytics. 184 | * 185 | * @returns {Promise} - Promise to be solved by the caller. 186 | */ 187 | async track(data) { 188 | switch (data.type) { 189 | case analyticsTrackTypes.SCREEN: 190 | return await this.processScreenEvent(data); 191 | 192 | case analyticsTrackTypes.TRACK: 193 | return await this.processTrackEvent(data); 194 | 195 | default: 196 | return this; 197 | } 198 | } 199 | 200 | /** 201 | * Entry point for screen view events processing. Will handle special screen views 202 | * which need to generate events as well to Firebase instead of only simple screen view events. 203 | * 204 | * @param data - Event data provided by analytics. 205 | * 206 | * @returns {Promise} Promise that will resolve when the method finishes. 207 | */ 208 | async processScreenEvent(data) { 209 | const eventName = get(data, 'event'); 210 | 211 | switch (eventName) { 212 | case screenTypes.BAG: 213 | case screenTypes.SEARCH: 214 | case screenTypes.WISHLIST: 215 | return await Promise.all([ 216 | this.processTrackEvent({ ...data, type: analyticsTrackTypes.TRACK }), 217 | this.trackScreen(data), 218 | ]); 219 | default: 220 | return await this.trackScreen(data); 221 | } 222 | } 223 | 224 | /** 225 | * Logs a screen view event with Firebase. 226 | * 227 | * @async 228 | * 229 | * @param {object} data - Event data provided by analytics. 230 | * 231 | * @returns {Promise} - Promise that will be resolved with this integration instance. 232 | */ 233 | async trackScreen(data) { 234 | const eventMapperFn = this.getEventMapper(data); 235 | 236 | await this.executeEventMapperAndSendEvent( 237 | data, 238 | eventMapperFn, 239 | defaultScreenViewsMapper, 240 | ); 241 | 242 | return this; 243 | } 244 | 245 | /** 246 | * Entry point for events processing. Will handle the case of some special events 247 | * which will generate more than one event for Firebase. For example, a single "PRODUCT_UPDATED" 248 | * event might generate up to 3 (virtual) events for Firebase depending on the event payload. 249 | * 250 | * @param {object} data - Event data provided by analytics. 251 | * 252 | * @returns {Promise} Promise that will resolve when the events associated with the event data is resolved. 253 | */ 254 | async processTrackEvent(data) { 255 | // Check if event generates virtual events 256 | const virtualEvents = getVirtualEventsFromEvent(data); 257 | 258 | const hasVirtualEvents = 259 | Array.isArray(virtualEvents) && virtualEvents.length > 0; 260 | 261 | // If virtual events exist for this event, track them and abort the tracking of the original event 262 | if (hasVirtualEvents) { 263 | return await Promise.all( 264 | virtualEvents.map(virtualEventData => 265 | this.trackEvent(virtualEventData), 266 | ), 267 | ); 268 | } 269 | 270 | // If no virtual events exist for this event, track the original event 271 | return await this.trackEvent(data); 272 | } 273 | 274 | /** 275 | * Tracks an event by invoking the configured event mapper for the event and using its output 276 | * to determine the event to be tracked with the @react-native-firebase/analytics instance. 277 | * If the mapper returns a "method" property from the invocation, it will be used to determine the 278 | * high-level method of the firebase instance to use to track the event. If "method" is not specified, 279 | * 'logEvent' method will be used instead and an "event" property must be returned from the mapper. 280 | * 281 | * @async 282 | * 283 | * @param {object} data - Event data provided by analytics. 284 | * 285 | * @returns {Promise} - Promise that will be resolved with this integration instance. 286 | */ 287 | async trackEvent(data) { 288 | const eventMapperFn = this.getEventMapper(data); 289 | 290 | await this.executeEventMapperAndSendEvent( 291 | data, 292 | eventMapperFn, 293 | defaultEventsMapper, 294 | ); 295 | 296 | return this; 297 | } 298 | 299 | /** 300 | * Gets the event mapper that will map the received event from analytics. 301 | * Will return either a custom mapper if there is one defined by the user or 302 | * a default mapper for the event type. 303 | * 304 | * @param {object} data - Event data provided by analytics. 305 | * 306 | * @returns {*} A custom wrapper for the event if defined or the default mapper otherwise. 307 | */ 308 | getEventMapper(data) { 309 | const type = get(data, 'type'); 310 | const event = get(data, 'event'); 311 | 312 | const customMapperDefinitions = 313 | type === analyticsTrackTypes.SCREEN 314 | ? this.customScreenViewsMapper 315 | : this.customEventsMapper; 316 | 317 | if (Object.prototype.hasOwnProperty.call(customMapperDefinitions, event)) { 318 | return customMapperDefinitions[event]; 319 | } 320 | 321 | return type === analyticsTrackTypes.SCREEN 322 | ? defaultScreenViewsMapper 323 | : defaultEventsMapper; 324 | } 325 | 326 | /** 327 | * Executes event mapper for the tracked event/screen and apply the output 328 | * to firebase analytics instance. 329 | * 330 | * @param {object} data - Event data provided by analytics. 331 | * @param {function} eventMapperFn - Event mapper function. 332 | * @param {function} defaultMapper - Default event mapper function. 333 | * @returns {Promise} Promise that will return when firebase analytics instance method is executed. 334 | */ 335 | async executeEventMapperAndSendEvent(data, eventMapperFn, defaultMapper) { 336 | const event = get(data, 'event'); 337 | const type = get(data, 'type'); 338 | const eventTypeDescription = 339 | type === analyticsTrackTypes.SCREEN ? 'screen view' : 'event'; 340 | 341 | if (eventMapperFn) { 342 | if (typeof eventMapperFn !== 'function') { 343 | utils.logger.error( 344 | `${MESSAGE_PREFIX} TypeError: Mapper for ${eventTypeDescription} "${event}" is not a function. If you're passing a custom mapper for this ${eventTypeDescription}, make sure a function is passed.`, 345 | ); 346 | 347 | return; 348 | } 349 | } 350 | 351 | const { method, event: firebaseEvent, properties } = 352 | eventMapperFn(data, defaultMapper) || {}; 353 | 354 | if (properties && typeof properties !== 'object') { 355 | utils.logger.error( 356 | `${MESSAGE_PREFIX} TypeError: The properties passed for ${eventTypeDescription} "${event}" is not an object. If you are passing a custom mapper for this ${eventTypeDescription}, make sure you return a valid object under "properties" key.`, 357 | ); 358 | 359 | return; 360 | } 361 | 362 | if (method && method !== 'logEvent') { 363 | const firebaseAnalyticsMethod = firebaseAnalytics()[method]; 364 | 365 | if (!firebaseAnalyticsMethod) { 366 | utils.logger.error( 367 | `${MESSAGE_PREFIX} Received invalid method "${method}" for ${eventTypeDescription} "${event}". If you are passing a custom mapper, make sure you return a supported Firebase Analytics method.`, 368 | ); 369 | 370 | return; 371 | } 372 | 373 | await firebaseAnalytics()[method](properties); 374 | 375 | return this; 376 | } else if (firebaseEvent) { 377 | // 'logEvent' will be used by default only if there is a corresponding event to track 378 | await firebaseAnalytics().logEvent(firebaseEvent, properties); 379 | } 380 | } 381 | 382 | /** 383 | * Overrides super.setConsent to update the consent values 384 | * on the native side (via react-native-firebase) by matching 385 | * the consent config with the user's given consent. 386 | * 387 | * @param {object} consentData - Consent object containing the user consent. 388 | */ 389 | async setConsent(consentData) { 390 | if (this.googleConsentConfigWithoutMode && consentData) { 391 | // Dealing with null or undefined consent values 392 | const safeConsent = consentData || {}; 393 | 394 | // Fill consent value into consent element, using analytics consent categories 395 | const consentValues = Object.keys( 396 | this.googleConsentConfigWithoutMode, 397 | ).reduce((result, consentKey) => { 398 | let consentValue = false; 399 | 400 | const consent = this.googleConsentConfigWithoutMode[consentKey]; 401 | 402 | if (consent && consent.categories) { 403 | consentValue = consent.categories.every( 404 | consentCategory => safeConsent[consentCategory], 405 | ); 406 | } 407 | 408 | return { 409 | ...result, 410 | [consentKey]: consentValue, 411 | }; 412 | }, {}); 413 | 414 | await firebaseAnalytics().setConsent(consentValues); 415 | 416 | // If in basic mode we need to activate analytics collection 417 | // We do not need to look for the consent variable because this method 418 | // will only be called if the integration was loaded, i.e., the 419 | // statistics consent was given. 420 | if ( 421 | get(this.options, `${OPTION_GOOGLE_CONSENT_CONFIG}.mode`, 'Basic') === 422 | 'Basic' 423 | ) { 424 | await firebaseAnalytics().setAnalyticsCollectionEnabled(true); 425 | } 426 | } 427 | } 428 | } 429 | 430 | export default FirebaseAnalytics; 431 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/firebaseAnalytics/__tests__/utils.test.js: -------------------------------------------------------------------------------- 1 | import { buildCustomEventsMapper } from '../utils'; 2 | 3 | describe('utils', () => { 4 | it('buildCustomEventsMapper should return a merged mapped object', () => { 5 | const key = 'properties'; 6 | const integrationOptions = { [key]: { bar: 'biz' } }; 7 | 8 | const mapper = buildCustomEventsMapper(integrationOptions, key); 9 | 10 | expect(mapper).toMatchObject({ 11 | ...integrationOptions[key], 12 | }); 13 | 14 | expect(mapper).not.toBe(integrationOptions[key]); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/firebaseAnalytics/constants.js: -------------------------------------------------------------------------------- 1 | export const MAX_PRODUCT_CATEGORIES = 5; 2 | 3 | export const OPTION_EVENTS_MAPPER = 'eventsMapper'; 4 | export const OPTION_SCREEN_VIEWS_MAPPER = 'screenViewsMapper'; 5 | export const OPTION_ON_SET_USER_HANDLER = 'onSetUser'; 6 | export const OPTION_SET_CUSTOM_USER_ID_PROPERTY = 'setCustomUserIdProperty'; 7 | export const OPTION_GOOGLE_CONSENT_CONFIG = 'googleConsentConfig'; 8 | 9 | export const MESSAGE_PREFIX = '[FirebaseAnalytics]'; 10 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/firebaseAnalytics/defaultMappers/defaultEventsMapper.js: -------------------------------------------------------------------------------- 1 | import { getMappedEventPropertiesForEvent } from './getMappedEventPropertiesForEvent'; 2 | import { utils } from '@farfetch/blackout-core/analytics'; 3 | import eventTypes from '../../../eventTypes'; 4 | import screenTypes from '../../../screenTypes'; 5 | import VirtualEventTypes from './virtualEventTypes'; 6 | import { MESSAGE_PREFIX } from '../constants'; 7 | 8 | export const firebaseEventNameMappings = { 9 | [eventTypes.APP_OPENED]: 'app_open', 10 | [eventTypes.PRODUCT_ADDED_TO_CART]: 'add_to_cart', 11 | [eventTypes.PRODUCT_REMOVED_FROM_CART]: 'remove_from_cart', 12 | [eventTypes.PAYMENT_INFO_ADDED]: 'add_payment_info', 13 | [eventTypes.PRODUCT_ADDED_TO_WISHLIST]: 'add_to_wishlist', 14 | [eventTypes.PRODUCT_REMOVED_FROM_WISHLIST]: 'remove_from_wishlist', 15 | [eventTypes.SHIPPING_INFO_ADDED]: 'add_shipping_info', 16 | [eventTypes.CHECKOUT_STARTED]: 'begin_checkout', 17 | [eventTypes.ORDER_COMPLETED]: 'purchase', 18 | [eventTypes.ORDER_REFUNDED]: 'refund', 19 | [eventTypes.SELECT_CONTENT]: 'select_content', 20 | [eventTypes.PRODUCT_CLICKED]: 'select_item', 21 | [eventTypes.PRODUCT_VIEWED]: 'view_item', 22 | [eventTypes.PRODUCT_LIST_VIEWED]: 'view_item_list', 23 | [eventTypes.LOGIN]: 'login', 24 | [eventTypes.SIGNUP_FORM_COMPLETED]: 'sign_up', 25 | [eventTypes.FILTERS_APPLIED]: 'apply_filters', 26 | [eventTypes.FILTERS_CLEARED]: 'clear_filters', 27 | [eventTypes.SHARE]: 'share', 28 | [eventTypes.CHECKOUT_ABANDONED]: 'abandon_confirmation_checkout', 29 | [eventTypes.PLACE_ORDER_STARTED]: 'place_order', 30 | [eventTypes.PROMOCODE_APPLIED]: 'apply_promo_code', 31 | [eventTypes.CHECKOUT_STEP_EDITING]: 'edit_checkout_step', 32 | [eventTypes.ADDRESS_INFO_ADDED]: 'add_address_info', 33 | [eventTypes.SHIPPING_METHOD_ADDED]: 'add_shipping_method', 34 | [eventTypes.INTERACT_CONTENT]: 'interact_content', 35 | [eventTypes.SIGNUP_NEWSLETTER]: 'sign_up_newsletter', 36 | [screenTypes.SEARCH]: 'search', 37 | [screenTypes.BAG]: 'view_cart', 38 | [screenTypes.WISHLIST]: 'view_wishlist', 39 | // virtual events 40 | [VirtualEventTypes.PRODUCT_UPDATED.CHANGE_QUANTITY]: 41 | VirtualEventTypes.PRODUCT_UPDATED.CHANGE_QUANTITY, 42 | [VirtualEventTypes.PRODUCT_UPDATED.CHANGE_SIZE]: 43 | VirtualEventTypes.PRODUCT_UPDATED.CHANGE_SIZE, 44 | [VirtualEventTypes.PRODUCT_UPDATED.CHANGE_COLOUR]: 45 | VirtualEventTypes.PRODUCT_UPDATED.CHANGE_COLOUR, 46 | }; 47 | 48 | /** 49 | * Default events mapper for Firebase. 50 | * 51 | * @param {object} data - Event data provided by analytics. 52 | * @returns {(object|undefined)} - Mapped event object for Firebase if the event is in the supported list. 53 | */ 54 | export default function defaultEventsMapper(data) { 55 | const event = data.event; 56 | const firebaseEvent = firebaseEventNameMappings[event]; 57 | 58 | if (!firebaseEvent) { 59 | return; 60 | } 61 | 62 | try { 63 | const firebaseEventProperties = getMappedEventPropertiesForEvent( 64 | event, 65 | data, 66 | ); 67 | 68 | return { 69 | event: firebaseEvent, 70 | properties: firebaseEventProperties, 71 | }; 72 | } catch (e) { 73 | utils.logger.error( 74 | `${MESSAGE_PREFIX} An error occurred when trying to map event "${event}": ${e}`, 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/firebaseAnalytics/defaultMappers/defaultScreenViewsMapper.js: -------------------------------------------------------------------------------- 1 | import isObject from 'lodash/isObject'; 2 | import snakeCase from 'lodash/snakeCase'; 3 | 4 | export default function defaultScreenViewsMapper(data) { 5 | const screenName = data.event; 6 | const properties = data.properties; 7 | 8 | const mappedProperties = { 9 | screen_name: screenName, 10 | screen_class: screenName, 11 | }; 12 | 13 | if (properties) { 14 | const propertyKeys = Object.keys(properties); 15 | 16 | if (propertyKeys.length > 0) { 17 | propertyKeys.forEach(key => { 18 | const value = properties[key]; 19 | 20 | // Discard values that are not primitives 21 | if (isObject(value) || typeof value === 'symbol') { 22 | return; 23 | } 24 | 25 | mappedProperties[snakeCase(key)] = value; 26 | }); 27 | } 28 | } 29 | 30 | return { 31 | method: 'logScreenView', 32 | properties: mappedProperties, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/firebaseAnalytics/defaultMappers/getVirtualEventsFromEvent.js: -------------------------------------------------------------------------------- 1 | import eventTypes from '../../../eventTypes'; 2 | import VirtualEventTypes from './virtualEventTypes'; 3 | 4 | /** 5 | * Returns virtual events to track from the 'PRODUCT_UPDATED' event, which can trigger 6 | * multiple events to Firebase, depending on the event payload. 7 | * 8 | * @param {object} data - Event data provided by analytics. 9 | * 10 | * @returns List of virtual events which will need to be triggered. 11 | */ 12 | const getProductUpdatedVirtualEvents = data => { 13 | const eventProperties = data.properties; 14 | const productUpdatedVirtualEvents = []; 15 | 16 | if ( 17 | eventProperties.quantity && 18 | eventProperties.oldQuantity !== eventProperties.quantity 19 | ) { 20 | productUpdatedVirtualEvents.push({ 21 | ...data, 22 | event: VirtualEventTypes.PRODUCT_UPDATED.CHANGE_QUANTITY, 23 | }); 24 | } 25 | 26 | if ( 27 | eventProperties.size && 28 | eventProperties.oldSize !== eventProperties.size 29 | ) { 30 | productUpdatedVirtualEvents.push({ 31 | ...data, 32 | event: VirtualEventTypes.PRODUCT_UPDATED.CHANGE_SIZE, 33 | }); 34 | } 35 | 36 | if ( 37 | eventProperties.colour && 38 | eventProperties.oldColour !== eventProperties.colour 39 | ) { 40 | productUpdatedVirtualEvents.push({ 41 | ...data, 42 | event: VirtualEventTypes.PRODUCT_UPDATED.CHANGE_COLOUR, 43 | }); 44 | } 45 | 46 | return productUpdatedVirtualEvents; 47 | }; 48 | 49 | /** 50 | * Returns the list of virtual events that are generated by the event 51 | * data passed as argument, if any. 52 | * 53 | * @param {object} data - Event data provided by analytics. 54 | * @returns {(Array|undefined)} An array of virtual event data if any are generated, else undefined. 55 | */ 56 | export default function getVirtualEventsFromEvent(data) { 57 | const event = data.event; 58 | 59 | if (event === eventTypes.PRODUCT_UPDATED) { 60 | return getProductUpdatedVirtualEvents(data); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/firebaseAnalytics/defaultMappers/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | default as defaultEventsMapper, 3 | firebaseEventNameMappings, 4 | } from './defaultEventsMapper'; 5 | export { default as defaultScreenViewsMapper } from './defaultScreenViewsMapper'; 6 | export { default as getVirtualEventsFromEvent } from './getVirtualEventsFromEvent'; 7 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/firebaseAnalytics/defaultMappers/virtualEventTypes.js: -------------------------------------------------------------------------------- 1 | export default { 2 | PRODUCT_UPDATED: { 3 | CHANGE_SIZE: 'change_size', 4 | CHANGE_QUANTITY: 'change_quantity', 5 | CHANGE_COLOUR: 'change_colour', 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/firebaseAnalytics/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './FirebaseAnalytics'; 2 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/firebaseAnalytics/utils.js: -------------------------------------------------------------------------------- 1 | import merge from 'lodash/merge'; 2 | import get from 'lodash/get'; 3 | 4 | /** 5 | * Gets the custom event mappers object from the options configured by the user 6 | * when adding the integration to analytics. 7 | * Will copy the values specified in a new object. 8 | * 9 | * @param {object} options - User configured options. 10 | * @param {object} key - The key of the custom mapper. 11 | * 12 | * @returns {object} - A new object containing all custom event mappers 13 | */ 14 | export const buildCustomEventsMapper = (options, key) => { 15 | const customEventsMapper = get(options, key, {}); 16 | const mapper = merge({}, customEventsMapper); 17 | 18 | return mapper; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/forter/constants.js: -------------------------------------------------------------------------------- 1 | export const OPTION_SITE_ID = 'siteId'; 2 | export const OPTION_ORIGIN = 'origin'; 3 | export const OPTION_NAVIGATION_EVENT_HANDLERS = 'navigationEventHandlers'; 4 | export const OPTION_ACTION_EVENT_HANDLERS = 'actionEventHandlers'; 5 | export const OPTION_ON_SET_USER_HANDLER = 'onSetUser'; 6 | 7 | export const USER_AGENT = 'React Native'; 8 | 9 | export const FORTER_TOKEN_ID = 1364; 10 | export const FORTER_TOKEN_LOADED_ANALYTICS_EVENT = 'forterTokenLoaded'; 11 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/forter/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Forter'; 2 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/forter/utils/defaultActionCommandBuilder.js: -------------------------------------------------------------------------------- 1 | import eventTypes from '../../../eventTypes'; 2 | 3 | let ForterActionType; 4 | 5 | try { 6 | ForterActionType = require('react-native-forter').ForterActionType; 7 | } catch (e) { 8 | // Set it to a default object so it does not throw when importing and 9 | // react-native-forter is not installed. 10 | ForterActionType = {}; 11 | } 12 | 13 | const eventTypesMap = { 14 | [eventTypes.PRODUCT_ADDED_TO_CART]: ForterActionType.ADD_TO_CART, 15 | [eventTypes.PRODUCT_REMOVED_FROM_CART]: ForterActionType.REMOVE_FROM_CART, 16 | [eventTypes.LOGIN]: ForterActionType.ACCOUNT_LOGIN, 17 | [eventTypes.LOGOUT]: ForterActionType.ACCOUNT_LOGOUT, 18 | [eventTypes.ORDER_COMPLETED]: ForterActionType.PAYMENT_INFO, 19 | [eventTypes.PLACE_ORDER_FAILED]: ForterActionType.PAYMENT_INFO, 20 | }; 21 | 22 | /** 23 | * This command builder will handle all track events that are not handled 24 | * by the user and that have a corresponding ForterActionType. 25 | * 26 | * @param {object} data - Event data provided by analytics. 27 | * 28 | * @returns {object} A command with the description of the method to be invoked in forterSDK's instance. 29 | */ 30 | export default data => { 31 | const eventName = data.event; 32 | 33 | const eventType = eventTypesMap[eventName]; 34 | 35 | if (!eventType) { 36 | return null; 37 | } 38 | 39 | return { 40 | method: 'trackActionWithJSON', 41 | args: [eventType, data.properties], 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/forter/utils/defaultNavigationCommandBuilder.js: -------------------------------------------------------------------------------- 1 | import { utils } from '@farfetch/blackout-core/analytics'; 2 | import get from 'lodash/get'; 3 | import screenTypes from '../../../screenTypes'; 4 | 5 | let ForterNavigationType; 6 | 7 | try { 8 | ForterNavigationType = require('react-native-forter').ForterNavigationType; 9 | } catch (e) { 10 | // Set it to a default object so it does not throw when importing and 11 | // react-native-forter is not installed. 12 | ForterNavigationType = {}; 13 | } 14 | 15 | /** 16 | * Default screen types mappings to ForterNavigationType enumeration. 17 | */ 18 | const screenTypesMap = { 19 | [screenTypes.ABOUT]: ForterNavigationType.HELP, 20 | [screenTypes.ARTICLE]: ForterNavigationType.PRODUCT, 21 | [screenTypes.ACCOUNT]: ForterNavigationType.ACCOUNT, 22 | [screenTypes.BAG]: ForterNavigationType.CART, 23 | [screenTypes.BIOGRAPHY]: ForterNavigationType.HELP, 24 | [screenTypes.CHECKOUT]: ForterNavigationType.CHECKOUT, 25 | [screenTypes.CHECKOUT_DELIVERY_METHOD]: ForterNavigationType.CHECKOUT, 26 | [screenTypes.CHECKOUT_PAYMENT]: ForterNavigationType.CHECKOUT, 27 | [screenTypes.CHECKOUT_REVIEW]: ForterNavigationType.CHECKOUT, 28 | [screenTypes.CHECKOUT_SHIPPING]: ForterNavigationType.CHECKOUT, 29 | [screenTypes.COLLECTIONS]: ForterNavigationType.PRODUCT, 30 | [screenTypes.COOKIE_PREFERENCES]: ForterNavigationType.HELP, 31 | [screenTypes.CORPORATE]: ForterNavigationType.HELP, 32 | [screenTypes.CUSTOMER_SERVICE]: ForterNavigationType.HELP, 33 | [screenTypes.DESIGNERS]: ForterNavigationType.SEARCH, 34 | [screenTypes.GENDER_SELECTION]: ForterNavigationType.ACCOUNT, 35 | [screenTypes.GENERIC_ERROR]: ForterNavigationType.HELP, 36 | [screenTypes.HOMEPAGE]: ForterNavigationType.PRODUCT, 37 | [screenTypes.JOURNAL]: ForterNavigationType.PRODUCT, 38 | [screenTypes.LOGIN]: ForterNavigationType.ACCOUNT, 39 | [screenTypes.LOGIN_REGISTER]: ForterNavigationType.ACCOUNT, 40 | [screenTypes.NEW_IN]: ForterNavigationType.PRODUCT, 41 | [screenTypes.NOT_FOUND]: ForterNavigationType.PRODUCT, 42 | [screenTypes.ORDER_CONFIRMATION]: ForterNavigationType.CHECKOUT, 43 | [screenTypes.PRODUCT_DETAILS]: ForterNavigationType.PRODUCT, 44 | [screenTypes.PRODUCT_LISTING]: ForterNavigationType.PRODUCT, 45 | [screenTypes.RECOVER_PASSWORD]: ForterNavigationType.ACCOUNT, 46 | [screenTypes.REGISTER]: ForterNavigationType.ACCOUNT, 47 | [screenTypes.RESET_PASSWORD]: ForterNavigationType.ACCOUNT, 48 | [screenTypes.RETURNS]: ForterNavigationType.ACCOUNT, 49 | [screenTypes.SALE]: ForterNavigationType.PRODUCT, 50 | [screenTypes.SEARCH]: ForterNavigationType.SEARCH, 51 | [screenTypes.SOCIAL]: ForterNavigationType.ACCOUNT, 52 | [screenTypes.STORES]: ForterNavigationType.HELP, 53 | [screenTypes.UNSUBSCRIBE]: ForterNavigationType.ACCOUNT, 54 | [screenTypes.WISHLIST]: ForterNavigationType.PRODUCT, 55 | }; 56 | 57 | /** 58 | * This command builder will handle all screen events that are not handled 59 | * by the user and that have a corresponding ForterNavigationType. 60 | * 61 | * If the event is considered to be a PRODUCT navigation type, 62 | * then will try to add the itemId and itemCategory of the product if available in 63 | * the properties, as specified in (requires authentication): 64 | * https://portal.forter.com/docs/ios/content/sending_event_data/track_navigation 65 | * 66 | * NOTE: The methods on that page refer to the native iOS implementation which have 67 | * different names in react-native-forter package, so do not be surprised to 68 | * have name mismatches. 69 | * 70 | * @param {object} data - Event data provided by analytics. 71 | * 72 | * @returns {object} A command with the description of the method to be invoked in forterSDK's instance. 73 | */ 74 | export default data => { 75 | const screenName = data.event; 76 | const screenType = screenTypesMap[screenName]; 77 | 78 | if (!screenType) { 79 | return null; 80 | } 81 | 82 | let itemId; 83 | let itemCategory; 84 | 85 | if (screenType === ForterNavigationType.PRODUCT) { 86 | itemId = get(data, 'properties.productId'); 87 | itemCategory = get(data, 'properties.productCategory'); 88 | 89 | if (!itemId || !itemCategory) { 90 | utils.logger.warn( 91 | `[ForterIntegration] - The screen view event '${screenName}' is categorised as a Product navigation type by default but productId, productCategory or both were missing from event properties. A navigation event will be sent to forter instance anyway but please review the code tracking this screen view event to add the missing values to the properties payload if possible. productId was '${itemId}', productCategory was '${itemCategory}'`, 92 | ); 93 | } 94 | 95 | return { 96 | method: 'trackNavigationWithExtraData', 97 | args: [ 98 | screenName, 99 | screenType, 100 | itemId ? `${itemId}` : null, 101 | itemCategory ? `${itemCategory}` : null, 102 | ], 103 | }; 104 | } 105 | 106 | return { 107 | method: 'trackNavigation', 108 | args: [screenName, screenType], 109 | }; 110 | }; 111 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/integration/index.js: -------------------------------------------------------------------------------- 1 | export { default } from '@farfetch/blackout-core/analytics/integrations/Integration'; 2 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/omnitracking/index.js: -------------------------------------------------------------------------------- 1 | export { default } from '@farfetch/blackout-core/analytics/integrations/Omnitracking/Omnitracking'; 2 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/integrations/shared/dataMappings/index.js: -------------------------------------------------------------------------------- 1 | export const SignupNewsletterGenderMappings = { 2 | 0: 'Woman', 3 | 1: 'Man', 4 | 2: 'Unisex', 5 | 3: 'Kids', 6 | }; 7 | -------------------------------------------------------------------------------- /packages/react-native-analytics/src/screenTypes.js: -------------------------------------------------------------------------------- 1 | import { pageTypes } from '@farfetch/blackout-core/analytics'; 2 | 3 | //For now, we are assuming the same pageTypes from @farfetch/blackout-core. 4 | //This will allow existing integrations that make use of it, to continue working. 5 | export default { 6 | ...pageTypes, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/react-native-metro-transformer/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [0.2.1](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-metro-transformer@0.2.0...@farfetch/blackout-react-native-metro-transformer@0.2.1) (2022-04-13) 7 | 8 | **Note:** Version bump only for package @farfetch/blackout-react-native-metro-transformer 9 | 10 | # 0.2.0 (2021-11-25) 11 | 12 | ### Features 13 | 14 | - migrate packages ([5a64fc5](https://github.com/Farfetch/blackout-react-native/commit/5a64fc58cb5f9cbdf600100f1c6315fa30889845)) 15 | -------------------------------------------------------------------------------- /packages/react-native-metro-transformer/README.md: -------------------------------------------------------------------------------- 1 | # @farfetch/blackout-react-native-metro-transformer 2 | 3 | Custom transformer for `metro` to be used by FPS react-native apps. 4 | 5 | Imported `package.json` files will have all their fields removed from the bundle, except for the `name` and `version` fields. This reduces final bundle size. 6 | 7 | ## Installation 8 | 9 | **yarn** 10 | 11 | ```sh 12 | yarn add @farfetch/blackout-react-native-metro-transformer 13 | ``` 14 | 15 | **npm** 16 | 17 | ```sh 18 | npm i @farfetch/blackout-react-native-metro-transformer 19 | ``` 20 | 21 | ## Usage 22 | 23 | > This was tested for `metro` versions 0.59 and higher but it is possible that it works in lower versions of `metro` if they support the `transformerPath` configuration key in `metro.config.js`. 24 | > Make sure the version of `metro` you are using satisfies this constraint. 25 | 26 | Configure the transformer for your app: 27 | 28 | - Create a `metro.config.js` file in the root directory of your react-native project. 29 | - Edit the file to include a `transformerPath` pointing to this module. 30 | ```js 31 | module.exports = { 32 | transformerPath: require.resolve( 33 | '@farfetch/blackout-react-native-metro-transformer', 34 | ), 35 | }; 36 | ``` 37 | 38 | ## Contributing 39 | 40 | Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change. 41 | 42 | Please read the [CONTRIBUTING](../../CONTRIBUTING.md) file to know what we expect from your contribution and the guidelines you should follow. 43 | 44 | ## License 45 | 46 | [MIT](../../LICENSE) @ Farfetch 47 | -------------------------------------------------------------------------------- /packages/react-native-metro-transformer/__mocks__/metro-transform-worker.js: -------------------------------------------------------------------------------- 1 | module.exports = { transform: () => {}, getCacheKey: () => {} }; 2 | -------------------------------------------------------------------------------- /packages/react-native-metro-transformer/__mocks__/metro/src/JSTransformer/worker.js: -------------------------------------------------------------------------------- 1 | module.exports = class {}; 2 | -------------------------------------------------------------------------------- /packages/react-native-metro-transformer/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | const legacyTransformer = require('../src/transformers/legacyTransformer'); 2 | const modernTransformer = require('../src/transformers/modernTransformer'); 3 | 4 | let mockLegacyTransformer; 5 | 6 | jest.mock('../src/transformers/legacyTransformer', () => { 7 | if (!mockLegacyTransformer) { 8 | mockLegacyTransformer = jest.fn(); 9 | } 10 | }); 11 | 12 | let mockModernTransformer; 13 | 14 | jest.mock('../src/transformers/modernTransformer', () => { 15 | if (!mockModernTransformer) { 16 | mockModernTransformer = jest.fn(); 17 | } 18 | }); 19 | 20 | describe('react-native-metro-transformer', () => { 21 | beforeEach(() => { 22 | jest.resetModules(); 23 | }); 24 | 25 | it('Should return legacy transformer if metro package version is less than 0.60', () => { 26 | jest.doMock('metro/package.json', () => { 27 | return { version: '0.59.0' }; 28 | }); 29 | 30 | require('metro/package.json'); 31 | 32 | const returnedValue = require('..'); 33 | 34 | expect(returnedValue).toBe(legacyTransformer); 35 | }); 36 | 37 | it('Should return modern transformer if metro package version is greater or equal than 0.60', () => { 38 | jest.doMock('metro/package.json', () => { 39 | return { version: '0.60.0' }; 40 | }); 41 | 42 | require('metro/package.json'); 43 | 44 | const returnedValue = require('..'); 45 | 46 | expect(returnedValue).toBe(modernTransformer); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /packages/react-native-metro-transformer/index.js: -------------------------------------------------------------------------------- 1 | var semver = require('semver'); 2 | var { version } = require('metro/package.json'); 3 | 4 | if (semver.satisfies(version, '>= 0.60.0')) { 5 | module.exports = require('./src/transformers/modernTransformer'); 6 | } else { 7 | module.exports = require('./src/transformers/legacyTransformer'); 8 | } 9 | -------------------------------------------------------------------------------- /packages/react-native-metro-transformer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@farfetch/blackout-react-native-metro-transformer", 3 | "version": "0.2.1", 4 | "description": "Custom transformer for metro to be used by FPS react-native apps", 5 | "main": "index.js", 6 | "license": "MIT", 7 | "devDependencies": { 8 | "metro": "^0.66.2" 9 | }, 10 | "peerDependencies": { 11 | "metro": ">= 0.59.0" 12 | }, 13 | "publishConfig": { 14 | "access": "public" 15 | }, 16 | "dependencies": { 17 | "metro": "0.70.1" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/react-native-metro-transformer/src/transformers/__tests__/legacyTransformer.test.js: -------------------------------------------------------------------------------- 1 | const legacyTransformer = require('../legacyTransformer'); 2 | const { Buffer } = require('buffer'); 3 | 4 | const UpstreamTransformer = require('metro/src/JSTransformer/worker'); 5 | 6 | UpstreamTransformer.prototype.transform = jest.fn(); 7 | 8 | describe('legacyTransformer', () => { 9 | beforeEach(() => { 10 | jest.clearAllMocks(); 11 | }); 12 | 13 | it('Should export a constructor function that creates instance of upstream transformer', () => { 14 | expect(legacyTransformer).toBeInstanceOf(Function); 15 | 16 | const transformerInstance = new legacyTransformer(); 17 | 18 | expect(transformerInstance).toBeInstanceOf(UpstreamTransformer); 19 | 20 | expect(transformerInstance).toHaveProperty('transform'); 21 | }); 22 | 23 | it('Should filter data before calling upstream transformer when the file to transform is a package.json file', () => { 24 | const transformerInstance = new legacyTransformer(); 25 | 26 | const mockFilename = 'myModule/package.json'; 27 | const dummyPackageJsonValue = { 28 | name: 'myModule', 29 | version: '1.0.0', 30 | dependencies: { 'dummy-dependency': '~0.1.0' }, 31 | repository: { 32 | type: 'git', 33 | url: 'git@gitrepository:myModule', 34 | }, 35 | }; 36 | const mockTransformOptions = { minify: true }; 37 | 38 | const mockData = Buffer.from(JSON.stringify(dummyPackageJsonValue)); 39 | 40 | const expectedData = Buffer.from( 41 | JSON.stringify({ 42 | name: dummyPackageJsonValue.name, 43 | version: dummyPackageJsonValue.version, 44 | }), 45 | ); 46 | 47 | transformerInstance.transform(mockFilename, mockData, mockTransformOptions); 48 | 49 | expect(UpstreamTransformer.prototype.transform).toHaveBeenCalledWith( 50 | mockFilename, 51 | expectedData, 52 | mockTransformOptions, 53 | ); 54 | }); 55 | 56 | it('Should not filter data before calling upstream transformer when the file to transform is not a package.json file', () => { 57 | const transformerInstance = new legacyTransformer(); 58 | 59 | let mockFilename = 'myModule/another.json'; 60 | let dummyFileContentsValue = { 61 | dummy: 'value', 62 | another: 'test', 63 | }; 64 | const mockTransformOptions = { minify: true }; 65 | 66 | let mockData = Buffer.from(JSON.stringify(dummyFileContentsValue)); 67 | 68 | transformerInstance.transform(mockFilename, mockData, mockTransformOptions); 69 | 70 | expect(UpstreamTransformer.prototype.transform).toHaveBeenLastCalledWith( 71 | mockFilename, 72 | mockData, 73 | mockTransformOptions, 74 | ); 75 | 76 | mockFilename = 'myModule/MyComponent.js'; 77 | 78 | dummyFileContentsValue = 79 | 'import react from "react"; export default class MyComponent extends React.Component {};'; 80 | 81 | mockData = Buffer.from(dummyFileContentsValue); 82 | 83 | transformerInstance.transform(mockFilename, mockData, mockTransformOptions); 84 | 85 | expect(UpstreamTransformer.prototype.transform).toHaveBeenLastCalledWith( 86 | mockFilename, 87 | mockData, 88 | mockTransformOptions, 89 | ); 90 | }); 91 | 92 | it('Should throw an error if the upstream transformer is not of the expected type', () => { 93 | jest.doMock('metro/src/JSTransformer/worker', () => { 94 | return {}; 95 | }); 96 | 97 | jest.resetModules(); 98 | 99 | expect(() => require('../legacyTransformer')).toThrow( 100 | 'Invalid value received for upstream transformer. This version of metro is not supported by this package.', 101 | ); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /packages/react-native-metro-transformer/src/transformers/__tests__/modernTransformer.test.js: -------------------------------------------------------------------------------- 1 | const modernTransformer = require('../modernTransformer'); 2 | const { Buffer } = require('buffer'); 3 | 4 | const UpstreamTransformer = require('metro-transform-worker'); 5 | 6 | UpstreamTransformer.transform = jest.fn(); 7 | 8 | describe('modernTransformer', () => { 9 | beforeEach(() => { 10 | jest.clearAllMocks(); 11 | }); 12 | 13 | it('Should export an object containing all properties from the upstream transformer object and overrides the transform function', () => { 14 | expect(modernTransformer).toMatchObject({ 15 | ...UpstreamTransformer, 16 | transform: expect.any(Function), 17 | }); 18 | }); 19 | 20 | it('Should filter data before calling upstream transformer when the file to transform is a package.json file', () => { 21 | const mockTransformerConfig = {}; 22 | const mockProjectRoot = 'my_project/'; 23 | const mockFilename = 'myModule/package.json'; 24 | const dummyPackageJsonValue = { 25 | name: 'myModule', 26 | version: '1.0.0', 27 | dependencies: { 'dummy-dependency': '~0.1.0' }, 28 | repository: { 29 | type: 'git', 30 | url: 'git@gitrepository:myModule', 31 | }, 32 | }; 33 | const mockTransformOptions = { minify: true }; 34 | 35 | const mockData = Buffer.from(JSON.stringify(dummyPackageJsonValue)); 36 | 37 | const expectedData = Buffer.from( 38 | JSON.stringify({ 39 | name: dummyPackageJsonValue.name, 40 | version: dummyPackageJsonValue.version, 41 | }), 42 | ); 43 | 44 | modernTransformer.transform( 45 | mockTransformerConfig, 46 | mockProjectRoot, 47 | mockFilename, 48 | mockData, 49 | mockTransformOptions, 50 | ); 51 | 52 | expect(UpstreamTransformer.transform).toHaveBeenCalledWith( 53 | mockTransformerConfig, 54 | mockProjectRoot, 55 | mockFilename, 56 | expectedData, 57 | mockTransformOptions, 58 | ); 59 | }); 60 | 61 | it('Should not filter data before calling upstream transformer when the file to transform is not a package.json file', () => { 62 | const mockTransformerConfig = {}; 63 | const mockProjectRoot = 'my_project/'; 64 | let mockFilename = 'myModule/another.json'; 65 | let dummyFileContentsValue = { 66 | dummy: 'value', 67 | another: 'test', 68 | }; 69 | const mockTransformOptions = { minify: true }; 70 | 71 | let mockData = Buffer.from(JSON.stringify(dummyFileContentsValue)); 72 | 73 | modernTransformer.transform( 74 | mockTransformerConfig, 75 | mockProjectRoot, 76 | mockFilename, 77 | mockData, 78 | mockTransformOptions, 79 | ); 80 | 81 | expect(UpstreamTransformer.transform).toHaveBeenLastCalledWith( 82 | mockTransformerConfig, 83 | mockProjectRoot, 84 | mockFilename, 85 | mockData, 86 | mockTransformOptions, 87 | ); 88 | 89 | mockFilename = 'myModule/MyComponent.js'; 90 | 91 | dummyFileContentsValue = 92 | 'import react from "react"; export default class MyComponent extends React.Component {};'; 93 | 94 | mockData = Buffer.from(dummyFileContentsValue); 95 | 96 | modernTransformer.transform( 97 | mockTransformerConfig, 98 | mockProjectRoot, 99 | mockFilename, 100 | mockData, 101 | mockTransformOptions, 102 | ); 103 | 104 | expect(UpstreamTransformer.transform).toHaveBeenLastCalledWith( 105 | mockTransformerConfig, 106 | mockProjectRoot, 107 | mockFilename, 108 | mockData, 109 | mockTransformOptions, 110 | ); 111 | }); 112 | 113 | it('Should throw an error if the upstream transformer is not of the expected type', () => { 114 | jest.doMock('metro-transform-worker', () => { 115 | return () => {}; 116 | }); 117 | 118 | jest.resetModules(); 119 | 120 | expect(() => require('../modernTransformer')).toThrow( 121 | 'Invalid value received for upstream transformer. This version of metro is not supported by this package.', 122 | ); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /packages/react-native-metro-transformer/src/transformers/legacyTransformer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Transformer to be used in React Native versions less or equal than 0.59 3 | */ 4 | 5 | const filterPackageJsonFields = require('../utils/filterPackageJsonFields'); 6 | 7 | // Default transformer class used by metro. 8 | const UpstreamTransformer = require('metro/src/JSTransformer/worker'); 9 | 10 | if (typeof UpstreamTransformer !== 'function') { 11 | throw new Error( 12 | 'Invalid value received for upstream transformer. This version of metro is not supported by this package.', 13 | ); 14 | } 15 | 16 | // Metro is expecting a class with a transform method in versions less or equal than 0.59. 17 | module.exports = class CustomMetroTransformer extends UpstreamTransformer { 18 | /** 19 | * Transforms the passed in file. If it is a package.json file, it will remove 20 | * all fields except 'name' and 'version' before passing to the upstream 21 | * transformer. 22 | * 23 | * @param {string} filename - The filename to be transformed. 24 | * @param {Buffer} data - Buffer containing the filename contents. It is assumed to be encoded in UTF-8. 25 | * @param {object} transformOptions - Transform options defined in metro. 26 | * 27 | * @returns {object} An object containing the transformation result. Can contain an Abstract Syntax Tree or not depending on the file type. 28 | */ 29 | transform(filename, data, transformOptions) { 30 | let finalData = data; 31 | 32 | // For package.json files, we filter 33 | // all fields, except 'name' and 'version' 34 | // before passing to the upstream transformer. 35 | if (filename.endsWith('package.json')) { 36 | finalData = filterPackageJsonFields(finalData); 37 | } 38 | 39 | // Then delegate the result to the upstream transformer. 40 | return super.transform(filename, finalData, transformOptions); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /packages/react-native-metro-transformer/src/transformers/modernTransformer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Transformer to be used in React Native versions greater than 0.59 3 | */ 4 | 5 | const filterPackageJsonFields = require('../utils/filterPackageJsonFields'); 6 | 7 | // Default transformer used by metro. 8 | const UpstreamTransformer = require('metro-transform-worker'); 9 | 10 | if (!UpstreamTransformer || typeof UpstreamTransformer !== 'object') { 11 | throw new Error( 12 | 'Invalid value received for upstream transformer. This version of metro is not supported by this package.', 13 | ); 14 | } 15 | 16 | // Metro is expecting an object with a transform method in versions greater than 0.59. 17 | module.exports = { 18 | ...UpstreamTransformer, 19 | /** 20 | * Transforms the passed in file. If it is a package.json file, it will remove 21 | * all fields except 'name' and 'version' before passing to the upstream 22 | * transformer. 23 | * 24 | * @param {object} transformerConfig - Transformer configuration defined in metro. 25 | * @param {string} projectRoot - Project root path. 26 | * @param {string} filename - The filename to be transformed. 27 | * @param {Buffer} data - Buffer containing the filename contents. It is assumed to be encoded in UTF-8. 28 | * @param {object} transformOptions - Transform options defined in metro. 29 | * 30 | * @returns {object} An object containing the transformation result. Can contain an Abstract Syntax Tree or not depending on the file type. 31 | */ 32 | transform(transformerConfig, projectRoot, filename, data, transformOptions) { 33 | let finalData = data; 34 | 35 | // For package.json files, we filter 36 | // all fields, except 'name' and 'version' 37 | // before passing to the upstream transformer. 38 | if (filename.endsWith('package.json')) { 39 | finalData = filterPackageJsonFields(finalData); 40 | } 41 | 42 | // Then delegate the result to the upstream transformer. 43 | return UpstreamTransformer.transform( 44 | transformerConfig, 45 | projectRoot, 46 | filename, 47 | finalData, 48 | transformOptions, 49 | ); 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /packages/react-native-metro-transformer/src/utils/filterPackageJsonFields.js: -------------------------------------------------------------------------------- 1 | const { Buffer } = require('buffer'); 2 | 3 | /** 4 | * Filters the passed in package.json data to return only 5 | * the name and version fields. 6 | * 7 | * @param {Buffer} data - Buffer containing the package.json data. Provided by metro when bundling. 8 | * 9 | * @returns {Buffer} Buffer containing only the name and version fields from package.json. 10 | */ 11 | function filterPackageJsonFields(data) { 12 | const sourceCodeString = data.toString('utf8'); 13 | 14 | const sourceCodeParsed = JSON.parse(sourceCodeString); 15 | 16 | const newSourceCodeString = JSON.stringify({ 17 | name: sourceCodeParsed.name, 18 | version: sourceCodeParsed.version, 19 | }); 20 | 21 | return Buffer.from(newSourceCodeString); 22 | } 23 | 24 | module.exports = filterPackageJsonFields; 25 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [0.6.4](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-riskified-integration@0.6.3...@farfetch/blackout-react-native-riskified-integration@0.6.4) (2024-02-27) 7 | 8 | **Note:** Version bump only for package @farfetch/blackout-react-native-riskified-integration 9 | 10 | ## [0.6.3](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-riskified-integration@0.6.2...@farfetch/blackout-react-native-riskified-integration@0.6.3) (2024-02-27) 11 | 12 | **Note:** Version bump only for package @farfetch/blackout-react-native-riskified-integration 13 | 14 | ## [0.6.2](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-riskified-integration@0.6.1...@farfetch/blackout-react-native-riskified-integration@0.6.2) (2024-02-26) 15 | 16 | **Note:** Version bump only for package @farfetch/blackout-react-native-riskified-integration 17 | 18 | ## [0.6.1](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-riskified-integration@0.6.0...@farfetch/blackout-react-native-riskified-integration@0.6.1) (2022-07-18) 19 | 20 | ### Bug Fixes 21 | 22 | - **riskified-integration:** upgrade `react-native` version to 0.69.1 ([4e736e3](https://github.com/Farfetch/blackout-react-native/commit/4e736e3f9b21e421512f336bf84e89da4d5c0168)) 23 | 24 | # [0.6.0](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-riskified-integration@0.5.2...@farfetch/blackout-react-native-riskified-integration@0.6.0) (2022-06-22) 25 | 26 | ### Features 27 | 28 | - **react-native-analytics:** update firebase mappings ([e2f6814](https://github.com/Farfetch/blackout-react-native/commit/e2f68146a735ca9b3637c7d46e5dd85c7df99729)) 29 | 30 | ### BREAKING CHANGES 31 | 32 | - **react-native-analytics:** Now all integrations will use the same events API 33 | that is being used by web applications. This means it is now possible 34 | to use the bag and wishlist redux middlewares as they will 35 | now be compatible with this implementation. 36 | Also, AnalyticsService integration was removed as it 37 | will not be supported in the future. 38 | 39 | ## [0.5.2](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-riskified-integration@0.5.1...@farfetch/blackout-react-native-riskified-integration@0.5.2) (2022-04-13) 40 | 41 | **Note:** Version bump only for package @farfetch/blackout-react-native-riskified-integration 42 | 43 | ## [0.5.1](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-riskified-integration@0.5.0...@farfetch/blackout-react-native-riskified-integration@0.5.1) (2022-03-29) 44 | 45 | ### Bug Fixes 46 | 47 | - **react-native-riskified-integration:** fix wrong import in Riskified ([4397c83](https://github.com/Farfetch/blackout-react-native/commit/4397c83ace11d180e06d6565c942ddfef043f0f7)) 48 | 49 | # [0.5.0](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-riskified-integration@0.4.3...@farfetch/blackout-react-native-riskified-integration@0.5.0) (2022-02-25) 50 | 51 | ### Features 52 | 53 | - **react-native-analytics:** add react-native castle.io integration ([b76d92c](https://github.com/Farfetch/blackout-react-native/commit/b76d92c8fbb279860d96144766ac6d101aae6609)) 54 | 55 | ## [0.4.3](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-riskified-integration@0.4.2...@farfetch/blackout-react-native-riskified-integration@0.4.3) (2022-02-22) 56 | 57 | ### Bug Fixes 58 | 59 | - fix android crash and update async-storage package ([c7e14cb](https://github.com/Farfetch/blackout-react-native/commit/c7e14cb0c3f881dc3149cd75398bfc48886e78c8)) 60 | 61 | ## [0.4.2](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-riskified-integration@0.4.1...@farfetch/blackout-react-native-riskified-integration@0.4.2) (2021-11-25) 62 | 63 | **Note:** Version bump only for package @farfetch/blackout-react-native-riskified-integration 64 | 65 | ## [0.4.1](https://github.com/Farfetch/blackout-react-native/compare/@farfetch/blackout-react-native-riskified-integration@0.4.0...@farfetch/blackout-react-native-riskified-integration@0.4.1) (2021-11-25) 66 | 67 | **Note:** Version bump only for package @farfetch/blackout-react-native-riskified-integration 68 | 69 | # 0.4.0 (2021-11-25) 70 | 71 | ### Features 72 | 73 | - migrate packages ([5a64fc5](https://github.com/Farfetch/blackout-react-native/commit/5a64fc58cb5f9cbdf600100f1c6315fa30889845)) 74 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/README.md: -------------------------------------------------------------------------------- 1 | # @farfetch/blackout-react-native-riskified-integration 2 | 3 | Riskified integration for @farfetch/blackout-react-native-analytics. 4 | 5 | ## Installation 6 | 7 | **yarn** 8 | 9 | ```sh 10 | yarn add @farfetch/blackout-react-native-riskified-integration 11 | ``` 12 | 13 | **npm** 14 | 15 | ```sh 16 | npm i @farfetch/blackout-react-native-riskified-integration 17 | ``` 18 | 19 | ### Peer dependencies 20 | 21 | Make sure that you have installed the correct Farfetch's peer dependencies: 22 | 23 | - [`@farfetch/blackout-react-native-analytics`](https://www.npmjs.com/package/@farfetch/blackout-react-native-analytics) 24 | - [`@farfetch/blackout-core`](https://www.npmjs.com/package/@farfetch/blackout-core) 25 | 26 | ### Autolinking 27 | 28 | Due to a [bug](https://github.com/react-native-community/cli/issues/938) on the `@react-native-community/cli` package, you will need to add a `react-native.config.js` file to the root of your react native project and declare the `packageName` for `android` project there, so that the cli can detect the correct package name for compilation: 29 | 30 | ```javascript 31 | // react-native.config.js 32 | module.exports = { 33 | project: { 34 | android: { 35 | packageName: 'package name of your android app', 36 | }, 37 | }, 38 | }; 39 | ``` 40 | 41 | ## Usage 42 | 43 | You will need to add the `Omnitracking` integration from `@farfetch/blackout-react-native-analytics` to your analytics instance. 44 | 45 | ```javascript 46 | import analytics, { 47 | } from '@farfetch/blackout-react-native-analytics'; 48 | import Riskified from '@farfetch/blackout-react-native-riskified-integration'; 49 | import Omnitracking from '@farfetch/blackout-react-native-analytics/integrations/omnitracking'; 50 | 51 | // Add the integration to analytics instance 52 | analytics.addIntegration('riskified', Riskified, { 53 | shopName: 'my shop name', // Required: The name of your Riskified account. 54 | token: '00000000-aaaa-0000-aaaa-000000000000', // Optional: The associated session token 55 | // A valid entry must exist in either `eventsToLog` or `screensToLog` options in order to the integration be correctly configured 56 | eventsToLog: { 57 | [eventTypes.PRODUCT_VIEWED]: 58 | 'URL that will be logged when a PRODUCT_VIEWED event is tracked in analytics', 59 | }, 60 | screensToLog: { 61 | [screenTypes.HOMEPAGE]: 62 | 'URL that will be logged when the HOMEPAGE screen is tracked in analytics', 63 | }, 64 | }); 65 | 66 | // Add `Omnitracking` integration is required for this integration to work correctly 67 | analytics.addIntegration('omnitracking', Omnitracking); 68 | ``` 69 | 70 | ### Options 71 | 72 | | Option name | Type | Required | Description | 73 | | -------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- | 74 | | `shopName` | string | yes | The name of your Riskified account. | 75 | | `token` | string | no | A unique identifier that is generated for the user’s current browsing session. If not provided, then `user.localId` will be used instead. | 76 | | `eventsToLog` | object | yes¹ | An object that contains a map of an event type to a URL string | 77 | | `screensToLog` | object | yes¹ | An object that contains a map of a screen type to a URL string | 78 | 79 | ¹ - Either eventsToLog or screensToLog must be passed with an object containing at least one entry. 80 | 81 | ### Session token 82 | 83 | If you provide a session token through the `token` option, make sure you are using the same token in `Omnitracking` integration through the `correlationId` context value so that order data that is sent to Riskified service contain the same session identifier in its `cart_token` property. 84 | If no session token is provided, `user.localId` is used instead and this value will be the same value that will be used in `Omnitracking` integration's `correlationId` field. 85 | 86 | ## Contributing 87 | 88 | Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change. 89 | 90 | Please read the [CONTRIBUTING](../../CONTRIBUTING.md) file to know what we expect from your contribution and the guidelines you should follow. 91 | 92 | ## License 93 | 94 | [MIT](../../LICENSE) @ Farfetch 95 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/android/.settings/org.eclipse.buildship.core.prefs: -------------------------------------------------------------------------------- 1 | arguments= 2 | auto.sync=false 3 | build.scans.enabled=false 4 | connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(6.3)) 5 | connection.project.dir= 6 | eclipse.preferences.version=1 7 | gradle.user.home= 8 | java.home=/Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home 9 | jvm.arguments= 10 | offline.mode=false 11 | override.workspace.settings=true 12 | show.console.view=true 13 | show.executions.view=true 14 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/android/README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | If you want to publish the lib as a maven dependency, follow these steps before publishing a new version to npm: 4 | 5 | 1. Be sure to have the Android [SDK](https://developer.android.com/studio/index.html) and [NDK](https://developer.android.com/ndk/guides/index.html) installed 6 | 2. Be sure to have a `local.properties` file in this folder that points to the Android SDK and NDK 7 | 8 | ``` 9 | ndk.dir=/Users/{username}/Library/Android/sdk/ndk-bundle 10 | sdk.dir=/Users/{username}/Library/Android/sdk 11 | ``` 12 | 13 | 3. Delete the `maven` folder 14 | 4. Run `./gradlew installArchives` 15 | 5. Verify that latest set of generated files is in the maven folder with the correct version number 16 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/android/build.gradle: -------------------------------------------------------------------------------- 1 | // android/build.gradle 2 | 3 | // based on: 4 | // 5 | // * https://github.com/facebook/react-native/blob/0.60-stable/template/android/build.gradle 6 | // original location: 7 | // - https://github.com/facebook/react-native/blob/0.58-stable/local-cli/templates/HelloWorld/android/build.gradle 8 | 9 | def DEFAULT_COMPILE_SDK_VERSION = 28 10 | def DEFAULT_BUILD_TOOLS_VERSION = '28.0.3' 11 | def DEFAULT_MIN_SDK_VERSION = 16 12 | def DEFAULT_TARGET_SDK_VERSION = 28 13 | 14 | def safeExtGet(prop, fallback) { 15 | rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback 16 | } 17 | 18 | apply plugin: 'com.android.library' 19 | apply plugin: 'maven-publish' 20 | 21 | buildscript { 22 | // The Android Gradle plugin is only required when opening the android folder stand-alone. 23 | // This avoids unnecessary downloads and potential conflicts when the library is included as a 24 | // module dependency in an application project. 25 | // ref: https://docs.gradle.org/current/userguide/tutorial_using_tasks.html#sec:build_script_external_dependencies 26 | if (project == rootProject) { 27 | repositories { 28 | google() 29 | jcenter() 30 | } 31 | dependencies { 32 | classpath 'com.android.tools.build:gradle:3.5.2' 33 | } 34 | } 35 | } 36 | 37 | apply plugin: 'com.android.library' 38 | 39 | android { 40 | compileSdkVersion safeExtGet('compileSdkVersion', DEFAULT_COMPILE_SDK_VERSION) 41 | buildToolsVersion safeExtGet('buildToolsVersion', DEFAULT_BUILD_TOOLS_VERSION) 42 | defaultConfig { 43 | minSdkVersion safeExtGet('minSdkVersion', DEFAULT_MIN_SDK_VERSION) 44 | targetSdkVersion safeExtGet('targetSdkVersion', DEFAULT_TARGET_SDK_VERSION) 45 | } 46 | lintOptions { 47 | abortOnError false 48 | } 49 | } 50 | 51 | repositories { 52 | // ref: https://www.baeldung.com/maven-local-repository 53 | mavenLocal() 54 | maven { 55 | // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm 56 | url "$rootDir/../node_modules/react-native/android" 57 | } 58 | maven { 59 | // Android JSC is installed from npm 60 | url "$rootDir/../node_modules/jsc-android/dist" 61 | } 62 | google() 63 | jcenter() 64 | } 65 | 66 | dependencies { 67 | //noinspection GradleDynamicVersion 68 | implementation 'com.facebook.react:react-native:+' // From node_modules 69 | implementation fileTree(dir: 'libs', include: ['*.jar','*.aar']) //Load all aars and jars from libs folder 70 | } 71 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/android/libs/riskifiedbeacon-release.aar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Farfetch/blackout-react-native/b01156989ff8e1e476b50633f05536e4251227f7/packages/react-native-riskified-integration/android/libs/riskifiedbeacon-release.aar -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/android/src/main/java/com/farfetch/reactnative/RiskifiedIntegrationModule.java: -------------------------------------------------------------------------------- 1 | package com.farfetch.reactnative; 2 | 3 | import android.app.Activity; 4 | import android.app.Application; 5 | import android.app.Application.ActivityLifecycleCallbacks; 6 | import android.os.Bundle; 7 | 8 | import com.android.riskifiedbeacon.RiskifiedBeaconMain; 9 | import com.android.riskifiedbeacon.RiskifiedBeaconMainInterface; 10 | import com.facebook.react.bridge.ReactApplicationContext; 11 | import com.facebook.react.bridge.ReactContextBaseJavaModule; 12 | import com.facebook.react.bridge.ReactMethod; 13 | import com.facebook.react.bridge.LifecycleEventListener; 14 | 15 | public class RiskifiedIntegrationModule extends ReactContextBaseJavaModule implements LifecycleEventListener { 16 | 17 | private final ReactApplicationContext reactContext; 18 | private final RiskifiedBeaconMainInterface RXBeacon = new RiskifiedBeaconMain(); 19 | 20 | public RiskifiedIntegrationModule(ReactApplicationContext reactContext) { 21 | super(reactContext); 22 | this.reactContext = reactContext; 23 | } 24 | 25 | @Override 26 | public String getName() { 27 | return "RiskifiedIntegration"; 28 | } 29 | 30 | @ReactMethod 31 | public void startBeacon(String shopName, String sessionToken, boolean debugInfo) { 32 | RXBeacon.startBeacon(shopName, sessionToken, debugInfo, this.reactContext); 33 | this.reactContext.addLifecycleEventListener(this); 34 | } 35 | 36 | @Override 37 | public void onHostResume() { 38 | } 39 | 40 | @Override 41 | public void onHostPause() { 42 | RXBeacon.removeLocationUpdates(); 43 | } 44 | 45 | @Override 46 | public void onHostDestroy() { 47 | } 48 | 49 | @ReactMethod 50 | public void updateSessionToken(String sessionToken) { 51 | RXBeacon.updateSessionToken(sessionToken); 52 | } 53 | 54 | @ReactMethod 55 | public void logRequest(String requestUrl) { 56 | RXBeacon.logRequest(requestUrl); 57 | } 58 | 59 | @ReactMethod 60 | public void logSensitiveDeviceInfo() { 61 | RXBeacon.logSensitiveDeviceInfo(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/android/src/main/java/com/farfetch/reactnative/RiskifiedIntegrationPackage.java: -------------------------------------------------------------------------------- 1 | package com.farfetch.reactnative; 2 | 3 | import java.util.Arrays; 4 | import java.util.Collections; 5 | import java.util.List; 6 | 7 | import com.facebook.react.ReactPackage; 8 | import com.facebook.react.bridge.NativeModule; 9 | import com.facebook.react.bridge.ReactApplicationContext; 10 | import com.facebook.react.uimanager.ViewManager; 11 | import com.facebook.react.bridge.JavaScriptModule; 12 | 13 | public class RiskifiedIntegrationPackage implements ReactPackage { 14 | @Override 15 | public List createNativeModules(ReactApplicationContext reactContext) { 16 | return Arrays.asList(new RiskifiedIntegrationModule(reactContext)); 17 | } 18 | 19 | @Override 20 | public List createViewManagers(ReactApplicationContext reactContext) { 21 | return Collections.emptyList(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/ios/RiskifiedIntegration.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface RiskifiedIntegration : NSObject 4 | 5 | @end 6 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/ios/RiskifiedIntegration.m: -------------------------------------------------------------------------------- 1 | #import "RiskifiedIntegration.h" 2 | #import "RiskifiedBeacon.h" 3 | 4 | @implementation RiskifiedIntegration 5 | 6 | RCT_EXPORT_MODULE() 7 | 8 | RCT_EXPORT_METHOD(startBeacon:(NSString *) shopName sessionToken:(NSString *) token debugInfo:(BOOL) enabled) 9 | { 10 | [RiskifiedBeacon startBeacon:shopName sessionToken:token debugInfo:enabled]; 11 | } 12 | 13 | RCT_EXPORT_METHOD(updateSessionToken:(NSString *) sessionToken) 14 | { 15 | [RiskifiedBeacon updateSessionToken:sessionToken]; 16 | } 17 | 18 | RCT_EXPORT_METHOD(logRequest:(NSURL *) requestUrl) 19 | { 20 | [RiskifiedBeacon logRequest:requestUrl]; 21 | } 22 | 23 | RCT_EXPORT_METHOD(logSensitiveDeviceInfo) 24 | { 25 | [RiskifiedBeacon logSensitiveDeviceInfo]; 26 | } 27 | 28 | @end 29 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/ios/RiskifiedIntegration.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | B3E7B58A1CC2AC0600A0062D /* RiskifiedIntegration.m in Sources */ = {isa = PBXBuildFile; fileRef = B3E7B5891CC2AC0600A0062D /* RiskifiedIntegration.m */; }; 11 | /* End PBXBuildFile section */ 12 | 13 | /* Begin PBXCopyFilesBuildPhase section */ 14 | 58B511D91A9E6C8500147676 /* CopyFiles */ = { 15 | isa = PBXCopyFilesBuildPhase; 16 | buildActionMask = 2147483647; 17 | dstPath = "include/$(PRODUCT_NAME)"; 18 | dstSubfolderSpec = 16; 19 | files = ( 20 | ); 21 | runOnlyForDeploymentPostprocessing = 0; 22 | }; 23 | /* End PBXCopyFilesBuildPhase section */ 24 | 25 | /* Begin PBXFileReference section */ 26 | 020F55482497E15E003415E8 /* RiskifiedBeacon.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RiskifiedBeacon.h; path = libs/RiskifiedBeacon.h; sourceTree = ""; }; 27 | 134814201AA4EA6300B7C361 /* libRiskifiedIntegration.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRiskifiedIntegration.a; sourceTree = BUILT_PRODUCTS_DIR; }; 28 | B3E7B5881CC2AC0600A0062D /* RiskifiedIntegration.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RiskifiedIntegration.h; sourceTree = ""; }; 29 | B3E7B5891CC2AC0600A0062D /* RiskifiedIntegration.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RiskifiedIntegration.m; sourceTree = ""; }; 30 | /* End PBXFileReference section */ 31 | 32 | /* Begin PBXFrameworksBuildPhase section */ 33 | 58B511D81A9E6C8500147676 /* Frameworks */ = { 34 | isa = PBXFrameworksBuildPhase; 35 | buildActionMask = 2147483647; 36 | files = ( 37 | ); 38 | runOnlyForDeploymentPostprocessing = 0; 39 | }; 40 | /* End PBXFrameworksBuildPhase section */ 41 | 42 | /* Begin PBXGroup section */ 43 | 020F55492497E16A003415E8 /* headers */ = { 44 | isa = PBXGroup; 45 | children = ( 46 | 020F55482497E15E003415E8 /* RiskifiedBeacon.h */, 47 | ); 48 | name = headers; 49 | sourceTree = ""; 50 | }; 51 | 134814211AA4EA7D00B7C361 /* Products */ = { 52 | isa = PBXGroup; 53 | children = ( 54 | 134814201AA4EA6300B7C361 /* libRiskifiedIntegration.a */, 55 | ); 56 | name = Products; 57 | sourceTree = ""; 58 | }; 59 | 58B511D21A9E6C8500147676 = { 60 | isa = PBXGroup; 61 | children = ( 62 | 020F55492497E16A003415E8 /* headers */, 63 | B3E7B5881CC2AC0600A0062D /* RiskifiedIntegration.h */, 64 | B3E7B5891CC2AC0600A0062D /* RiskifiedIntegration.m */, 65 | 134814211AA4EA7D00B7C361 /* Products */, 66 | ); 67 | sourceTree = ""; 68 | }; 69 | /* End PBXGroup section */ 70 | 71 | /* Begin PBXNativeTarget section */ 72 | 58B511DA1A9E6C8500147676 /* RiskifiedIntegration */ = { 73 | isa = PBXNativeTarget; 74 | buildConfigurationList = 58B511EF1A9E6C8500147676 /* Build configuration list for PBXNativeTarget "RiskifiedIntegration" */; 75 | buildPhases = ( 76 | 58B511D71A9E6C8500147676 /* Sources */, 77 | 58B511D81A9E6C8500147676 /* Frameworks */, 78 | 58B511D91A9E6C8500147676 /* CopyFiles */, 79 | ); 80 | buildRules = ( 81 | ); 82 | dependencies = ( 83 | ); 84 | name = RiskifiedIntegration; 85 | productName = RCTDataManager; 86 | productReference = 134814201AA4EA6300B7C361 /* libRiskifiedIntegration.a */; 87 | productType = "com.apple.product-type.library.static"; 88 | }; 89 | /* End PBXNativeTarget section */ 90 | 91 | /* Begin PBXProject section */ 92 | 58B511D31A9E6C8500147676 /* Project object */ = { 93 | isa = PBXProject; 94 | attributes = { 95 | LastUpgradeCheck = 1140; 96 | ORGANIZATIONNAME = Facebook; 97 | TargetAttributes = { 98 | 58B511DA1A9E6C8500147676 = { 99 | CreatedOnToolsVersion = 6.1.1; 100 | }; 101 | }; 102 | }; 103 | buildConfigurationList = 58B511D61A9E6C8500147676 /* Build configuration list for PBXProject "RiskifiedIntegration" */; 104 | compatibilityVersion = "Xcode 3.2"; 105 | developmentRegion = English; 106 | hasScannedForEncodings = 0; 107 | knownRegions = ( 108 | English, 109 | en, 110 | ); 111 | mainGroup = 58B511D21A9E6C8500147676; 112 | productRefGroup = 58B511D21A9E6C8500147676; 113 | projectDirPath = ""; 114 | projectRoot = ""; 115 | targets = ( 116 | 58B511DA1A9E6C8500147676 /* RiskifiedIntegration */, 117 | ); 118 | }; 119 | /* End PBXProject section */ 120 | 121 | /* Begin PBXSourcesBuildPhase section */ 122 | 58B511D71A9E6C8500147676 /* Sources */ = { 123 | isa = PBXSourcesBuildPhase; 124 | buildActionMask = 2147483647; 125 | files = ( 126 | B3E7B58A1CC2AC0600A0062D /* RiskifiedIntegration.m in Sources */, 127 | ); 128 | runOnlyForDeploymentPostprocessing = 0; 129 | }; 130 | /* End PBXSourcesBuildPhase section */ 131 | 132 | /* Begin XCBuildConfiguration section */ 133 | 58B511ED1A9E6C8500147676 /* Debug */ = { 134 | isa = XCBuildConfiguration; 135 | buildSettings = { 136 | ALWAYS_SEARCH_USER_PATHS = NO; 137 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; 138 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 139 | CLANG_CXX_LIBRARY = "libc++"; 140 | CLANG_ENABLE_MODULES = YES; 141 | CLANG_ENABLE_OBJC_ARC = YES; 142 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 143 | CLANG_WARN_BOOL_CONVERSION = YES; 144 | CLANG_WARN_COMMA = YES; 145 | CLANG_WARN_CONSTANT_CONVERSION = YES; 146 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 147 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 148 | CLANG_WARN_EMPTY_BODY = YES; 149 | CLANG_WARN_ENUM_CONVERSION = YES; 150 | CLANG_WARN_INFINITE_RECURSION = YES; 151 | CLANG_WARN_INT_CONVERSION = YES; 152 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 153 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 154 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 155 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 156 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 157 | CLANG_WARN_STRICT_PROTOTYPES = YES; 158 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 159 | CLANG_WARN_UNREACHABLE_CODE = YES; 160 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 161 | COPY_PHASE_STRIP = NO; 162 | ENABLE_STRICT_OBJC_MSGSEND = YES; 163 | ENABLE_TESTABILITY = YES; 164 | GCC_C_LANGUAGE_STANDARD = gnu99; 165 | GCC_DYNAMIC_NO_PIC = NO; 166 | GCC_NO_COMMON_BLOCKS = YES; 167 | GCC_OPTIMIZATION_LEVEL = 0; 168 | GCC_PREPROCESSOR_DEFINITIONS = ( 169 | "DEBUG=1", 170 | "$(inherited)", 171 | ); 172 | GCC_SYMBOLS_PRIVATE_EXTERN = NO; 173 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 174 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 175 | GCC_WARN_UNDECLARED_SELECTOR = YES; 176 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 177 | GCC_WARN_UNUSED_FUNCTION = YES; 178 | GCC_WARN_UNUSED_VARIABLE = YES; 179 | IPHONEOS_DEPLOYMENT_TARGET = 8.0; 180 | MTL_ENABLE_DEBUG_INFO = YES; 181 | ONLY_ACTIVE_ARCH = YES; 182 | SDKROOT = iphoneos; 183 | }; 184 | name = Debug; 185 | }; 186 | 58B511EE1A9E6C8500147676 /* Release */ = { 187 | isa = XCBuildConfiguration; 188 | buildSettings = { 189 | ALWAYS_SEARCH_USER_PATHS = NO; 190 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; 191 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 192 | CLANG_CXX_LIBRARY = "libc++"; 193 | CLANG_ENABLE_MODULES = YES; 194 | CLANG_ENABLE_OBJC_ARC = YES; 195 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 196 | CLANG_WARN_BOOL_CONVERSION = YES; 197 | CLANG_WARN_COMMA = YES; 198 | CLANG_WARN_CONSTANT_CONVERSION = YES; 199 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 200 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 201 | CLANG_WARN_EMPTY_BODY = YES; 202 | CLANG_WARN_ENUM_CONVERSION = YES; 203 | CLANG_WARN_INFINITE_RECURSION = YES; 204 | CLANG_WARN_INT_CONVERSION = YES; 205 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 206 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 207 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 208 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 209 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 210 | CLANG_WARN_STRICT_PROTOTYPES = YES; 211 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 212 | CLANG_WARN_UNREACHABLE_CODE = YES; 213 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 214 | COPY_PHASE_STRIP = YES; 215 | ENABLE_NS_ASSERTIONS = NO; 216 | ENABLE_STRICT_OBJC_MSGSEND = YES; 217 | GCC_C_LANGUAGE_STANDARD = gnu99; 218 | GCC_NO_COMMON_BLOCKS = YES; 219 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 220 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 221 | GCC_WARN_UNDECLARED_SELECTOR = YES; 222 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 223 | GCC_WARN_UNUSED_FUNCTION = YES; 224 | GCC_WARN_UNUSED_VARIABLE = YES; 225 | IPHONEOS_DEPLOYMENT_TARGET = 8.0; 226 | MTL_ENABLE_DEBUG_INFO = NO; 227 | SDKROOT = iphoneos; 228 | VALIDATE_PRODUCT = YES; 229 | }; 230 | name = Release; 231 | }; 232 | 58B511F01A9E6C8500147676 /* Debug */ = { 233 | isa = XCBuildConfiguration; 234 | buildSettings = { 235 | HEADER_SEARCH_PATHS = ( 236 | "$(inherited)", 237 | /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, 238 | "$(SRCROOT)/../../../React/**", 239 | "$(SRCROOT)/../../react-native/React/**", 240 | ); 241 | LIBRARY_SEARCH_PATHS = "$(inherited)"; 242 | OTHER_LDFLAGS = "-ObjC"; 243 | PRODUCT_NAME = RiskifiedIntegration; 244 | SKIP_INSTALL = YES; 245 | }; 246 | name = Debug; 247 | }; 248 | 58B511F11A9E6C8500147676 /* Release */ = { 249 | isa = XCBuildConfiguration; 250 | buildSettings = { 251 | HEADER_SEARCH_PATHS = ( 252 | "$(inherited)", 253 | /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, 254 | "$(SRCROOT)/../../../React/**", 255 | "$(SRCROOT)/../../react-native/React/**", 256 | ); 257 | LIBRARY_SEARCH_PATHS = "$(inherited)"; 258 | OTHER_LDFLAGS = "-ObjC"; 259 | PRODUCT_NAME = RiskifiedIntegration; 260 | SKIP_INSTALL = YES; 261 | }; 262 | name = Release; 263 | }; 264 | /* End XCBuildConfiguration section */ 265 | 266 | /* Begin XCConfigurationList section */ 267 | 58B511D61A9E6C8500147676 /* Build configuration list for PBXProject "RiskifiedIntegration" */ = { 268 | isa = XCConfigurationList; 269 | buildConfigurations = ( 270 | 58B511ED1A9E6C8500147676 /* Debug */, 271 | 58B511EE1A9E6C8500147676 /* Release */, 272 | ); 273 | defaultConfigurationIsVisible = 0; 274 | defaultConfigurationName = Release; 275 | }; 276 | 58B511EF1A9E6C8500147676 /* Build configuration list for PBXNativeTarget "RiskifiedIntegration" */ = { 277 | isa = XCConfigurationList; 278 | buildConfigurations = ( 279 | 58B511F01A9E6C8500147676 /* Debug */, 280 | 58B511F11A9E6C8500147676 /* Release */, 281 | ); 282 | defaultConfigurationIsVisible = 0; 283 | defaultConfigurationName = Release; 284 | }; 285 | /* End XCConfigurationList section */ 286 | }; 287 | rootObject = 58B511D31A9E6C8500147676 /* Project object */; 288 | } 289 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/ios/RiskifiedIntegration.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/ios/RiskifiedIntegration.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/ios/libs/RiskifiedBeacon.h: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013-2015 Riskified.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"). 5 | * You may not use this file except in compliance with the License. 6 | * A copy of the License is located at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0.html 9 | * 10 | * or in the "license" file accompanying this file. This file is distributed 11 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 12 | * express or implied. See the License for the specific language governing 13 | * permissions and limitations under the License. 14 | */ 15 | 16 | #import 17 | 18 | 19 | @interface RiskifiedBeacon : NSObject 20 | 21 | /** 22 | Entry point, should be called at the end of applicationDidFinishLaunching: 23 | @param shopName The Riskified account name (shop.com) 24 | @param term Regular expression to limit reporting to a specific domain 25 | @param token The initial session's unique identifier 26 | @param debugInfo Controls debug logging to NSLog 27 | */ 28 | + (void)startBeacon:(NSString *)shopName sessionToken:(NSString *)token debugInfo:(BOOL)enabled; 29 | 30 | /** 31 | Updates that the user has begun a new browsing session 32 | @param token The new session's unique identifier 33 | */ 34 | + (void)updateSessionToken:(NSString *)token; 35 | 36 | /** 37 | Manually log a request to a specific URL. 38 | @param url The remote url that the host app sent a request to. 39 | */ 40 | + (void)logRequest:(NSURL *)url; 41 | 42 | /** 43 | Manually log sensitive Personally Identifiable Information (social account data). 44 | */ 45 | + (void)logSensitiveDeviceInfo; 46 | 47 | @end 48 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/ios/libs/libriskifiedbeacon.a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Farfetch/blackout-react-native/b01156989ff8e1e476b50633f05536e4251227f7/packages/react-native-riskified-integration/ios/libs/libriskifiedbeacon.a -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@farfetch/blackout-react-native-riskified-integration", 3 | "title": "React Native Riskified Integration", 4 | "version": "0.6.4", 5 | "description": "Riskified integration for @farfetch/blackout-react-native-analytics", 6 | "main": "src/index.js", 7 | "license": "MIT", 8 | "files": [ 9 | "src/", 10 | "android/src", 11 | "android/libs", 12 | "android/build.gradle", 13 | "ios/", 14 | "react-native-riskified-integration.podspec" 15 | ], 16 | "homepage": "https://github.com/Farfetch/blackout-react-native/tree/main/packages/react-native-riskified-integration", 17 | "author": "Farfetch Platform Solutions", 18 | "devDependencies": { 19 | "@farfetch/blackout-react-native-analytics": "^0.11.1", 20 | "axios": "^0.21.4", 21 | "lodash": "^4.17.21", 22 | "react": "^16.8.1", 23 | "react-native": "^0.69.1" 24 | }, 25 | "peerDependencies": { 26 | "@farfetch/blackout-react-native-analytics": "^0.3.0", 27 | "lodash": "^4.17.21", 28 | "react-native": "^0.69.1" 29 | }, 30 | "publishConfig": { 31 | "access": "public" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/react-native-riskified-integration.podspec: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | package = JSON.parse(File.read(File.join(__dir__, "package.json"))) 4 | 5 | Pod::Spec.new do |s| 6 | s.name = "react-native-riskified-integration" 7 | s.version = package["version"] 8 | s.summary = package["description"] 9 | s.description = <<-DESC 10 | Riskified integration for @farfetch/blackout-react-native-analytics 11 | DESC 12 | s.homepage = package["homepage"] 13 | s.license = { :type => "MIT", :file => "LICENSE" } 14 | s.author = package["author"] 15 | s.platforms = { :ios => "9.0" } 16 | s.source = { :path => 'ios/**/*.{h,m,swift}' } 17 | s.source_files = "ios/**/*.{h,m,swift}" 18 | s.requires_arc = true 19 | 20 | s.dependency "React" 21 | s.vendored_libraries = "ios/libs/*.a" 22 | end 23 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/src/Riskified.js: -------------------------------------------------------------------------------- 1 | import { 2 | eventTypes, 3 | trackTypes, 4 | utils, 5 | } from '@farfetch/blackout-react-native-analytics'; 6 | import Integration from '@farfetch/blackout-react-native-analytics/integrations/integration'; 7 | import { NativeModules, Platform } from 'react-native'; 8 | import get from 'lodash/get'; 9 | 10 | const { RiskifiedIntegration } = NativeModules; 11 | export default class Riskified extends Integration { 12 | /** 13 | * Returns true due to being a required integration - No need to check for consent. 14 | * 15 | * @static 16 | * 17 | * @returns {Boolean} - Will return true to force loading of the integration. 18 | * 19 | * @memberof Riskified 20 | */ 21 | static shouldLoad() { 22 | return true; 23 | } 24 | 25 | /** 26 | * Method used to create a new Riskified instance by analytics. 27 | * 28 | * @static 29 | * 30 | * @param {Object} options - Integration options. 31 | * @param {Object} loadData - Analytics' load event data. 32 | * 33 | * @returns {Object} - An instance of Riskified class. 34 | * 35 | * @memberof Riskified 36 | */ 37 | static createInstance(options, loadData) { 38 | return new Riskified(options, loadData); 39 | } 40 | 41 | /** 42 | * Creates an instance of Riskified and starts the beacon if the 43 | * necessary data is provided. 44 | * 45 | * @param {Object} options - Custom options for the integration. 46 | * @param {Object} loadData - Analytics' load event data. 47 | * 48 | * @memberof Riskified# 49 | */ 50 | constructor(options, loadData) { 51 | super(options, loadData); 52 | 53 | this.initialize(loadData); 54 | } 55 | 56 | /** 57 | * Validates options passed to the integration and call startBeacon of the 58 | * native module RiskifiedIntegration with the correct parameters 59 | * 60 | * @param {Object} loadData - Load data provided by analytics 61 | * 62 | * @memberof Riskified# 63 | */ 64 | initialize(loadData) { 65 | const { shopName, token, eventsToLog, screensToLog } = this.options; 66 | 67 | if (!shopName || typeof shopName !== 'string') { 68 | throw new Error( 69 | 'Failed to initialize riskified integration: `shopName` option was not provided with a valid value', 70 | ); 71 | } 72 | 73 | const eventsToLogLength = 74 | typeof eventsToLog === 'object' ? Object.keys(eventsToLog).length : 0; 75 | const screensToLogLength = 76 | typeof screensToLog === 'object' ? Object.keys(screensToLog).length : 0; 77 | 78 | if (!eventsToLogLength && !screensToLogLength) { 79 | throw new Error( 80 | 'Failed to initialize riskified integration: no events or screen views were registered to be logged. Please, use the `eventsToLog` option to register the events that need to be logged and the `screensToLog` option to register the screen views that need to be logged', 81 | ); 82 | } 83 | 84 | let finalToken = token; 85 | 86 | //If a session token is not provided, use user.localId 87 | //as the token 88 | if (!finalToken) { 89 | const localId = get(loadData, 'user.localId'); 90 | finalToken = localId; 91 | } 92 | 93 | RiskifiedIntegration.startBeacon(shopName, finalToken, true); 94 | } 95 | 96 | /** 97 | * Overrides Integration.track method 98 | * 99 | * @param {Object} data - Track data provided by analytics. 100 | * 101 | * @memberof Riskified# 102 | */ 103 | track(data) { 104 | switch (data.type) { 105 | case trackTypes.SCREEN: { 106 | this.handleScreenView(data); 107 | break; 108 | } 109 | case trackTypes.TRACK: { 110 | this.handleEvent(data); 111 | } 112 | } 113 | } 114 | 115 | /** 116 | * Handles tracks of type "screen". If the screen tracked has a 117 | * URL configured to be logged, we log the url with riskified. Else, 118 | * we bail out. 119 | * 120 | * @param {Object} data - Track data provided by analytics. 121 | * 122 | * @memberof Riskified# 123 | */ 124 | handleScreenView(data) { 125 | const { event } = data; 126 | 127 | const { screensToLog } = this.options; 128 | 129 | if (!screensToLog || !screensToLog[event]) { 130 | return; 131 | } 132 | 133 | const requestUrlToLog = screensToLog[event]; 134 | 135 | if (typeof requestUrlToLog !== 'string') { 136 | utils.logger.error( 137 | `[Riskified] - Value specified for screensToLog[${event}] is not a string: ${typeof requestUrlToLog}. Aborting logRequest call.`, 138 | ); 139 | return; 140 | } 141 | 142 | RiskifiedIntegration.logRequest(requestUrlToLog); 143 | } 144 | 145 | /** 146 | * Handles tracks of type "track". If the event tracked is an order completed 147 | * event and the platform is android, we will call the native module 148 | * logSensitiveDeviceInfo method as recommended by riskified android sdk documentation. 149 | * Also, if there is a url configured to be logged, we log the url with riskified. 150 | * Else, we bail out. 151 | * 152 | * @param {Object} data - Track data provided by analytics 153 | * 154 | * @memberof Riskified# 155 | */ 156 | handleEvent(data) { 157 | const { event } = data; 158 | 159 | //If an order completed event is tracked 160 | //we need to call logSensitiveDeviceInfo 161 | //as the SDK documentation recommends only for Android 162 | if (event === eventTypes.ORDER_COMPLETED && Platform.OS === 'android') { 163 | RiskifiedIntegration.logSensitiveDeviceInfo(); 164 | } 165 | 166 | const { eventsToLog } = this.options; 167 | 168 | if (!eventsToLog || !eventsToLog[event]) { 169 | return; 170 | } 171 | 172 | const requestUrlToLog = eventsToLog[event]; 173 | 174 | if (typeof requestUrlToLog !== 'string') { 175 | utils.logger.error( 176 | `[Riskified] - Value specified for eventsToLog[${event}] is not a string: ${typeof requestUrlToLog}. Aborting logRequest call.`, 177 | ); 178 | return; 179 | } 180 | 181 | RiskifiedIntegration.logRequest(requestUrlToLog); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/src/__tests__/Riskified.test.js: -------------------------------------------------------------------------------- 1 | import Riskified from '../Riskified'; 2 | import { NativeModules, Platform } from 'react-native'; 3 | import { 4 | eventTypes, 5 | screenTypes, 6 | trackTypes, 7 | utils, 8 | } from '@farfetch/blackout-react-native-analytics'; 9 | 10 | utils.logger.error = jest.fn(); 11 | 12 | jest.mock('react-native', () => ({ 13 | NativeModules: { 14 | RiskifiedIntegration: { 15 | logRequest: jest.fn(), 16 | startBeacon: jest.fn(), 17 | logSensitiveDeviceInfo: jest.fn(), 18 | }, 19 | }, 20 | Platform: { 21 | OS: 'ios', 22 | }, 23 | })); 24 | 25 | jest.mock('@farfetch/blackout-react-native-analytics', () => { 26 | const original = jest.requireActual( 27 | '@farfetch/blackout-react-native-analytics', 28 | ); 29 | 30 | return { 31 | ...original, 32 | utils: { 33 | logger: { 34 | error: jest.fn(), 35 | }, 36 | }, 37 | }; 38 | }); 39 | 40 | const { RiskifiedIntegration } = NativeModules; 41 | 42 | const defaultOptions = { 43 | shopName: 'dummy_shop_name', 44 | eventsToLog: { 45 | [eventTypes.ORDER_COMPLETED]: 'http://www.example.com/order_completed', 46 | }, 47 | token: 'dummy_token', 48 | }; 49 | 50 | describe('Riskified integration', () => { 51 | beforeEach(() => { 52 | jest.clearAllMocks(); 53 | }); 54 | 55 | describe('Static methods', () => { 56 | it('should always return true when calling shouldLoad method', () => { 57 | expect( 58 | Riskified.shouldLoad({ 59 | marketing: false, 60 | preferences: false, 61 | statistics: false, 62 | }), 63 | ).toBe(true); 64 | }); 65 | 66 | it('should return a Riskified instance when calling createInstance method', () => { 67 | expect(Riskified.createInstance(defaultOptions, {})).toBeInstanceOf( 68 | Riskified, 69 | ); 70 | }); 71 | }); 72 | 73 | describe('Options Validation', () => { 74 | describe('shopName option', () => { 75 | it('should throw if options does not contain a valid shopName property', () => { 76 | const options = { 77 | eventsToLog: defaultOptions.eventsToLog, 78 | }; 79 | 80 | expect(() => new Riskified(options)).toThrowErrorMatchingInlineSnapshot( 81 | '"Failed to initialize riskified integration: `shopName` option was not provided with a valid value"', 82 | ); 83 | 84 | options.shopName = jest.fn(); 85 | 86 | expect(() => new Riskified(options)).toThrowErrorMatchingInlineSnapshot( 87 | '"Failed to initialize riskified integration: `shopName` option was not provided with a valid value"', 88 | ); 89 | }); 90 | }); 91 | 92 | describe('eventsToLog and screensToLog options', () => { 93 | it('should throw if options does not contain both eventsToLog and screensToLog options', () => { 94 | const options = { shopName: defaultOptions.shopName }; 95 | 96 | expect(() => new Riskified(options)).toThrowErrorMatchingInlineSnapshot( 97 | '"Failed to initialize riskified integration: no events or screen views were registered to be logged. Please, use the `eventsToLog` option to register the events that need to be logged and the `screensToLog` option to register the screen views that need to be logged"', 98 | ); 99 | 100 | options.eventsToLog = {}; 101 | 102 | expect(() => new Riskified(options)).toThrowErrorMatchingInlineSnapshot( 103 | '"Failed to initialize riskified integration: no events or screen views were registered to be logged. Please, use the `eventsToLog` option to register the events that need to be logged and the `screensToLog` option to register the screen views that need to be logged"', 104 | ); 105 | 106 | options.screensToLog = {}; 107 | 108 | expect(() => new Riskified(options)).toThrowErrorMatchingInlineSnapshot( 109 | '"Failed to initialize riskified integration: no events or screen views were registered to be logged. Please, use the `eventsToLog` option to register the events that need to be logged and the `screensToLog` option to register the screen views that need to be logged"', 110 | ); 111 | 112 | options.eventsToLog = 'invalid_value'; 113 | 114 | expect(() => new Riskified(options)).toThrowErrorMatchingInlineSnapshot( 115 | '"Failed to initialize riskified integration: no events or screen views were registered to be logged. Please, use the `eventsToLog` option to register the events that need to be logged and the `screensToLog` option to register the screen views that need to be logged"', 116 | ); 117 | 118 | options.screensToLog = 'invalid_value'; 119 | 120 | expect(() => new Riskified(options)).toThrowErrorMatchingInlineSnapshot( 121 | '"Failed to initialize riskified integration: no events or screen views were registered to be logged. Please, use the `eventsToLog` option to register the events that need to be logged and the `screensToLog` option to register the screen views that need to be logged"', 122 | ); 123 | }); 124 | }); 125 | 126 | describe('token option', () => { 127 | it('should use user.localId as the token for startBeacon call if the token option is not provided', () => { 128 | const localId = '633d0be8-50bf-4dff-a1af-dd1f68214b3b'; 129 | 130 | const loadData = { 131 | user: { 132 | localId, 133 | }, 134 | }; 135 | 136 | const optionsWithoutToken = { 137 | ...defaultOptions, 138 | }; 139 | 140 | delete optionsWithoutToken.token; 141 | 142 | // eslint-disable-next-line no-new 143 | new Riskified(optionsWithoutToken, loadData); 144 | 145 | expect(RiskifiedIntegration.startBeacon).toHaveBeenCalledWith( 146 | defaultOptions.shopName, 147 | localId, 148 | expect.any(Boolean), 149 | ); 150 | }); 151 | }); 152 | }); 153 | 154 | describe('Constructor', () => { 155 | it('should call startBeacon when the required options are provided', async () => { 156 | // eslint-disable-next-line no-new 157 | new Riskified(defaultOptions, {}); 158 | 159 | expect(RiskifiedIntegration.startBeacon).toHaveBeenCalledWith( 160 | defaultOptions.shopName, 161 | defaultOptions.token, 162 | expect.any(Boolean), 163 | ); 164 | }); 165 | }); 166 | 167 | describe('Track', () => { 168 | let riskifiedInstance; 169 | 170 | const options = { 171 | ...defaultOptions, 172 | screensToLog: { 173 | [screenTypes.HOMEPAGE]: 'http://www.example.com/home', 174 | [screenTypes.ACCOUNT]: jest.fn(), //This property is incorrectly configured on purpose for a test case 175 | }, 176 | eventsToLog: { 177 | [eventTypes.CHECKOUT_STARTED]: 178 | 'http://www.example.com/checkout_started', 179 | [eventTypes.PRODUCT_ADDED_TO_CART]: jest.fn(), //This property is incorrectly configured on purpose for a test case 180 | }, 181 | }; 182 | 183 | beforeEach(() => { 184 | riskifiedInstance = new Riskified(options); 185 | }); 186 | 187 | describe('Screen views', () => { 188 | it('should call logRequest if there is a url configured for the tracked screen', () => { 189 | riskifiedInstance.track({ 190 | type: trackTypes.SCREEN, 191 | event: screenTypes.HOMEPAGE, 192 | }); 193 | 194 | expect(RiskifiedIntegration.logRequest).toHaveBeenCalledWith( 195 | options.screensToLog[screenTypes.HOMEPAGE], 196 | ); 197 | }); 198 | 199 | it('should _NOT_ call logRequest if there is _NOT_ a url configured for the tracked screen', () => { 200 | riskifiedInstance.track({ 201 | type: trackTypes.SCREEN, 202 | event: screenTypes.LOGIN, 203 | }); 204 | 205 | expect(RiskifiedIntegration.logRequest).not.toHaveBeenCalled(); 206 | }); 207 | 208 | it('should _NOT_ call logRequest if the url configured is not a string and should display an error message', () => { 209 | riskifiedInstance.track({ 210 | type: trackTypes.SCREEN, 211 | event: screenTypes.ACCOUNT, 212 | }); 213 | 214 | expect(RiskifiedIntegration.logRequest).not.toHaveBeenCalled(); 215 | 216 | expect(utils.logger.error).toHaveBeenCalledWith( 217 | `[Riskified] - Value specified for screensToLog[${ 218 | screenTypes.ACCOUNT 219 | }] is not a string: ${typeof options.screensToLog[ 220 | screenTypes.ACCOUNT 221 | ]}. Aborting logRequest call.`, 222 | ); 223 | }); 224 | }); 225 | 226 | describe('Events', () => { 227 | it('should call logRequest if there is a url configured for the tracked event', () => { 228 | riskifiedInstance.track({ 229 | type: trackTypes.TRACK, 230 | event: eventTypes.CHECKOUT_STARTED, 231 | }); 232 | 233 | expect(RiskifiedIntegration.logRequest).toHaveBeenCalledWith( 234 | options.eventsToLog[eventTypes.CHECKOUT_STARTED], 235 | ); 236 | }); 237 | 238 | it('should _NOT_ call logRequest if there is _NOT_ a url configured for the tracked event', () => { 239 | riskifiedInstance.track({ 240 | type: trackTypes.TRACK, 241 | event: eventTypes.PLACE_ORDER_STARTED, 242 | }); 243 | 244 | expect(RiskifiedIntegration.logRequest).not.toHaveBeenCalled(); 245 | }); 246 | 247 | it('should _NOT_ call logRequest if the url configured is not a string and should display an error message', () => { 248 | riskifiedInstance.track({ 249 | type: trackTypes.TRACK, 250 | event: eventTypes.PRODUCT_ADDED_TO_CART, 251 | }); 252 | 253 | expect(RiskifiedIntegration.logRequest).not.toHaveBeenCalled(); 254 | 255 | expect(utils.logger.error).toHaveBeenCalledWith( 256 | `[Riskified] - Value specified for eventsToLog[${ 257 | eventTypes.PRODUCT_ADDED_TO_CART 258 | }] is not a string: ${typeof options.eventsToLog[ 259 | eventTypes.PRODUCT_ADDED_TO_CART 260 | ]}. Aborting logRequest call.`, 261 | ); 262 | }); 263 | 264 | it('should call logSensitiveDeviceInfo if the event is ORDER_COMPLETED and platform is android', () => { 265 | Platform.OS = 'android'; 266 | 267 | riskifiedInstance.track({ 268 | type: trackTypes.TRACK, 269 | event: eventTypes.ORDER_COMPLETED, 270 | }); 271 | 272 | expect(RiskifiedIntegration.logSensitiveDeviceInfo).toHaveBeenCalled(); 273 | 274 | expect(RiskifiedIntegration.logRequest).not.toHaveBeenCalled(); 275 | }); 276 | }); 277 | }); 278 | }); 279 | -------------------------------------------------------------------------------- /packages/react-native-riskified-integration/src/index.js: -------------------------------------------------------------------------------- 1 | import Riskified from './Riskified'; 2 | 3 | export default Riskified; 4 | --------------------------------------------------------------------------------