diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..596070199f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root=true + +[*] +charset=utf-8 +end_of_line=lf +indent_size=2 +indent_style=space +insert_final_newline=true +trim_trailing_whitespace=true + +[*.md] +max_line_length=off +trim_trailing_whitespace=false diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000000..9be58d11ae --- /dev/null +++ b/.eslintignore @@ -0,0 +1,6 @@ +.eslintrc.js +*.d.ts +*jest.config.js +dist/* +**/*.js +apps/desktop/wdio-logs/* diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000000..51fa2e300b --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,56 @@ +module.exports = { + parser: '@typescript-eslint/parser', + plugins: ['lodash'], + extends: [ + '@atixlabs/eslint-config/configurations/react', + 'plugin:@typescript-eslint/recommended', + 'plugin:import/typescript' + ], + settings: { + 'import/resolver': { typescript: { project: ['packages/*/src/tsconfig.json', 'apps/*/src/tsconfig.json'] } }, + // Fixes eslint not being able to detect react version + react: { pragma: 'React', fragment: 'Fragment', version: 'detect' } + }, + rules: { + quotes: ['error', 'single', { avoidEscape: true }], + 'new-cap': ['error', { properties: false }], + // needed for Cardano.* + 'unicorn/filename-case': 'off', + 'unicorn/prevent-abbreviations': 'off', + 'unicorn/prefer-object-from-entries': 'off', + 'unicorn/prefer-node-protocol': 'off', + 'unicorn/no-array-for-each': 'off', + 'unicorn/prefer-module': 'off', + 'promise/always-return': 'off', + 'no-use-before-define': 'off', + '@typescript-eslint/no-use-before-define': ['error'], + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': ['error'], + 'no-invalid-this': 0, + 'react/prop-types': 'off', + 'max-len': 'off', // prettier is already handling this automatically, + 'no-console': 'off', // Fine to disable here, prod webpack config strips console logs + 'lodash/import-scope': ['error', 'method'] + }, + overrides: [ + { + files: ['stories/desktop/**/*.stories.{ts,tsx}', 'stories/desktop/decorators/**'], + rules: { + 'react/no-multi-comp': [0, { ignoreStateless: true }] + } + }, + { + files: '**/*jest.config.js', + rules: { + '@typescript-eslint/no-var-requires': 'off' + } + }, + { + // https://github.com/SonarSource/eslint-plugin-sonarjs/issues/176 + files: ['**/*.{test,integration,spec}.{ts,tsx}'], + rules: { + 'sonarjs/no-duplicate-string': 'off' + } + } + ] +}; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..14968d0f8a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,13 @@ +# Root Level +* @input-output-hk/lace-admins @input-output-hk/lace-tech-leads + +# Packages Teams +/packages/ui @input-output-hk/lace-ui +/packages/staking @input-output-hk/lace-staking +/packages/cardano @input-output-hk/lace-core +/packages/common @input-output-hk/lace-core +/packages/core @input-output-hk/lace-core +/packages/e2e-test @input-output-hk/lace-test-engineers + +# Apps +/apps @input-output-hk/lace-core diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..29447d7273 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + time: '00:00' + timezone: UTC + open-pull-requests-limit: 10 + commit-message: + prefix: "chore" + include: "scope" diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000000..80457c255e --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,12 @@ +# Browser extension target +browser: + - any: ['apps/browser-extension-wallet/**/*'] +# Staking & Multi-delegation +staking: + - any: ['packages/staking/**/*'] +# E2E targets +e2e: + - any: ['features/**/*', 'packages/e2e-tests/**/*'] +# Docs targets +documentation: + - any: ['docs/**/*', '**/*.md'] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..c3130bb590 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +# Checklist + +- [ ] JIRA - \ +- [ ] Proper tests implemented +- [ ] Screenshots added. + +--- + +## Proposed solution + +Explain how does this PR solves the problem stated in JIRA ticket. +You can also enumerate different alternatives considered while approaching this task. + +## Testing + +Describe here, how the new implementation can be tested. +Provide link or briefly describe User Acceptance Criteria/Tests that need to be met + +## Screenshots + +Attach screenshots here if implementation involves some UI changes diff --git a/.github/scripts/audit.sh b/.github/scripts/audit.sh new file mode 100755 index 0000000000..9a39d1ffae --- /dev/null +++ b/.github/scripts/audit.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +yarn audit --groups dependencies --level critical; [[ $? -ge 16 ]] && exit 1 || exit 0; diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 0000000000..190a176879 --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,13 @@ +repository: + allow_merge_commit: true + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + delete_branch_on_merge: true + has_downloads: false + has_issues: true + has_projects: false + has_wiki: false + name: wallet-world + private: true + topics: lace diff --git a/.github/shared/build/action.yml b/.github/shared/build/action.yml new file mode 100644 index 0000000000..9868f5b1a7 --- /dev/null +++ b/.github/shared/build/action.yml @@ -0,0 +1,29 @@ +name: Shared Build +description: Shared build config for both Chromium and Safari workflows +inputs: + LACE_EXTENSION_KEY: + description: 'Public extended manifest key' + required: true +runs: + using: 'composite' + steps: + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + - name: Node modules cache + uses: actions/cache@v3 + with: + path: | + node_modules + **/node_modules + key: ${{ runner.os }}-${{ hashFiles('yarn.lock') }} + - name: Install dependencies + shell: bash + run: yarn install --frozen-lockfile --non-interactive --logevel=error + - name: Build dist version + shell: bash + env: + LACE_EXTENSION_KEY: ${{ inputs.LACE_EXTENSION_KEY }} + run: yarn browser build diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..78fb1b5b4d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - 'release/**' + +jobs: + buildAndTest: + name: Build & Test + runs-on: ubuntu-20.04 + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Decrypt test data + working-directory: ./packages/e2e-tests + run: ./decrypt_secret.sh + env: + WALLET_1_PASSWORD: ${{ secrets.WALLET_PASSWORD_TESTNET }} + - name: Audit packages + run: ./.github/scripts/audit.sh + - name: Build dist version of Lace + uses: ./.github/shared/build + with: + LACE_EXTENSION_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }} + - name: Check for linter issues + run: yarn lint + - name: Run unit tests + env: + AVAILABLE_CHAINS: 'Preprod,Preview,Mainnet' + DEFAULT_CHAIN: 'Preprod' + run: yarn test --maxWorkers=2 + - name: Upload build + uses: actions/upload-artifact@v3 + with: + name: lace + path: apps/browser-extension-wallet/dist diff --git a/.github/workflows/e2e-tests-linux.yml b/.github/workflows/e2e-tests-linux.yml new file mode 100644 index 0000000000..130bbb0efc --- /dev/null +++ b/.github/workflows/e2e-tests-linux.yml @@ -0,0 +1,139 @@ +name: E2E Tests Linux + +on: + schedule: + - cron: '0 0 * * *' + push: + branches: + - 'release/**' + workflow_dispatch: + inputs: + tags: + description: 'Test scenario tags (will run all tests if empty)' + required: false + browser: + description: 'Browser to use' + required: true + default: 'chrome' + type: choice + options: + - chrome + - edge + network: + type: choice + description: network to use + options: + - preprod + - mainnet + +run-name: "E2E | os: Linux | browser: ${{ github.event.inputs.browser || 'chrome' }} | tags: ${{ github.event.inputs.tags || 'empty' }} | network: ${{ github.event.inputs.network }} | #${{ github.run_number }}" + +env: + TAGS: ${{ github.event.inputs.tags || 'empty' }} + BROWSER: ${{ github.event.inputs.browser || 'chrome' }} + NETWORK: ${{ github.event.inputs.network || 'preprod' }} + RUN: ${{ github.run_number }} + DISPLAY: ':99.0' + +jobs: + tests: + runs-on: ubuntu-20.04 + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Decrypt test data + working-directory: ./packages/e2e-tests + run: ./decrypt_secret.sh + env: + WALLET_1_PASSWORD: ${{ secrets.WALLET_PASSWORD_TESTNET }} + - name: Build dist version of Lace + uses: ./.github/shared/build + with: + LACE_EXTENSION_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }} + - name: Start XVFB + run: | + Xvfb :99 & + - name: Start Chrome driver + working-directory: /usr/local/share/chrome_driver + run: | + if [ ${BROWSER} == "chrome" ]; then + ./chromedriver -port=4444 & + else + echo "Skipping start of ChromeDriver" + fi + - name: Start Edge driver + working-directory: /usr/local/share/edge_driver + run: | + if [ ${BROWSER} == "edge" ]; then + ./msedgedriver -port=4444 & + else + echo "Skipping start of EdgeDriver" + fi + - name: Execute E2E tests + id: e2e-tests + working-directory: ./packages/e2e-tests + env: + WALLET_1_PASSWORD: ${{ secrets.WALLET_PASSWORD_TESTNET }} + TEST_DAPP_URL: ${{ secrets.TEST_DAPP_URL }} + ENV: ${{ env.NETWORK }} + run: | + if [ "$TAGS" == "empty" ]; then + TAGS_TO_RUN=""; + else + TAGS_TO_RUN="--cucumberOpts.tagExpression=${TAGS}"; + fi + ./node_modules/.bin/wdio run wdio.conf.${BROWSER}.ts $TAGS_TO_RUN + - name: Create allure properties + if: always() + working-directory: ./packages/e2e-tests/reports/allure/results + run: | + pendingCount=`grep -r "@Pending" ../../../src/features | wc -l | xargs` + pending=`grep -r -h "@Pending" ../../../src/features -A 2 | grep -v -- "^--$" | sed "s/@Pending//g" | awk '{$1=$1};1' | sed "/^@/s/ /./g;/^#/s/ /./g" | xargs | sed "s/@[^ ]*/\n&/g" | sed "s/ /./"` + echo " + env=${NETWORK} + browser=${BROWSER} + tags=${TAGS} + platform=Linux + Pending.Count=${pendingCount} + @Pending.Scenarios= + ${pending} + " > environment.properties + - name: Publish allure report to S3 + uses: andrcuns/allure-publish-action@v1.0.1 + if: always() + env: + GITHUB_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AWS_ACCESS_KEY_ID: ${{ secrets.E2E_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.E2E_AWS_ACCESS_KEY }} + with: + storageType: s3 + resultsGlob: './packages/e2e-tests/reports/allure/results/*' + bucket: lightwallet + prefix: 'linux/${BROWSER}/${RUN}' + copyLatest: true + ignoreMissingResults: true + - name: Publish artifacts (logs, reports, screenshots) + uses: actions/upload-artifact@v3 + if: always() + with: + name: test-artifacts + path: | + ./packages/e2e-tests/screenshots + ./packages/e2e-tests/logs + ./packages/e2e-tests/reports + retention-days: 10 + - name: Add link to summary + if: always() + run: | + echo "TEST RESULTS:" + echo "https://${{ secrets.E2E_REPORTS_USER }}:${{ secrets.E2E_REPORTS_PASSWORD }}@${{ secrets.E2E_REPORTS_URL }}/linux/${{ env.BROWSER }}/${{ env.RUN }}/index.html | tags: ${{ env.TAGS }} | browser: ${{ env.BROWSER }} | network: ${{ env.NETWORK }} | platform: linux" >> $GITHUB_STEP_SUMMARY + - name: Slack Notification + uses: rtCamp/action-slack-notify@v2.2.0 + if: always() + env: + SLACK_COLOR: ${{ job.status }} + SLACK_ICON_EMOJI: ':lace:' + SLACK_MESSAGE: 'https://${{ secrets.E2E_REPORTS_USER }}:${{ secrets.E2E_REPORTS_PASSWORD }}@${{ secrets.E2E_REPORTS_URL }}/linux/${{ env.BROWSER }}/${{ env.RUN }}/index.html | tags: ${{ env.TAGS }} | browser: ${{ env.BROWSER }} | network: ${{ env.NETWORK }} | platform: linux' + SLACK_TITLE: 'Test automation results :rocket:' + SLACK_USERNAME: lace-qa-bot + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} diff --git a/.github/workflows/e2e-tests-win.yml b/.github/workflows/e2e-tests-win.yml new file mode 100644 index 0000000000..c9aeacbde7 --- /dev/null +++ b/.github/workflows/e2e-tests-win.yml @@ -0,0 +1,185 @@ +name: E2E Tests Win + +on: + schedule: + - cron: '0 2 * * *' + push: + branches: + - 'release/**' + workflow_dispatch: + inputs: + tags: + description: 'Test scenario tags (will run all tests if empty)' + required: false + browser: + description: 'Browser to use' + required: true + default: 'edge' + type: choice + options: + - chrome + - edge + network: + type: choice + description: network to use + options: + - preprod + - mainnet + +run-name: "E2E | os: Windows | browser: ${{ github.event.inputs.browser || 'chrome' }} | tags: ${{ github.event.inputs.tags || 'empty' }} | network: ${{ github.event.inputs.network }} | #${{ github.run_number }}" + +env: + TAGS: ${{ github.event.inputs.tags || 'empty' }} + BROWSER: ${{ github.event.inputs.browser || 'edge' }} + NETWORK: ${{ github.event.inputs.network || 'preprod' }} + RUN: ${{ github.run_number }} + +jobs: + build-extension-linux: + runs-on: ubuntu-20.04 + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Decrypt test data + working-directory: ./packages/e2e-tests + run: ./decrypt_secret.sh + env: + WALLET_1_PASSWORD: ${{ secrets.WALLET_PASSWORD_TESTNET }} + - name: Build dist version of Lace + uses: ./.github/shared/build + with: + LACE_EXTENSION_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }} + - name: Save Lace extension build + uses: actions/upload-artifact@v3 + with: + name: lace-build + path: ./apps/browser-extension-wallet/dist + run-tests-windows: + needs: build-extension-linux + runs-on: windows-2022 + defaults: + run: + shell: bash + steps: + - name: Set screen resolution + shell: pwsh + run: Set-DisplayResolution -Width 1920 -Height 1080 -Force + - name: Checkout repository + uses: actions/checkout@v3 + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + - name: Get built extension + uses: actions/download-artifact@v3 + with: + name: lace-build + path: ./apps/browser-extension-wallet/dist + - name: Node modules cache + uses: actions/cache@v3 + with: + path: ./packages/e2e-tests/node_modules + key: ${{ runner.os }}-${{ hashFiles('yarn.lock') }} + - name: Install dependencies + working-directory: ./packages/e2e-tests + run: yarn install --check-cache --frozen-lockfile --non-interactive --loglevel=error --ignore-scripts + - name: Start Chrome driver + working-directory: C:\SeleniumWebDrivers\ChromeDriver\ + run: | + if [ ${BROWSER} == "chrome" ]; then + chromedriver.exe -port=4444 & + else + echo "Skipping start of ChromeDriver" + fi + - name: Start Edge driver + working-directory: C:\SeleniumWebDrivers\EdgeDriver\ + run: | + if [ ${BROWSER} == "edge" ]; then + msedgedriver.exe -port=4444 & + else + echo "Skipping start of EdgeDriver" + fi + - name: Execute E2E tests + working-directory: ./packages/e2e-tests + id: e2e-tests + env: + WALLET_1_PASSWORD: ${{ secrets.WALLET_PASSWORD_TESTNET }} + TEST_DAPP_URL: ${{ secrets.TEST_DAPP_URL }} + ENV: ${{ env.NETWORK }} + run: | + if [ "$TAGS" == "empty" ]; then + TAGS_TO_RUN=""; + else + TAGS_TO_RUN="--cucumberOpts.tagExpression=${TAGS}"; + fi + ./node_modules/.bin/wdio run wdio.conf.${BROWSER}.ts $TAGS_TO_RUN + - name: Create allure properties + if: always() + working-directory: ./packages/e2e-tests/reports/allure/results + run: | + pendingCount=`grep -r "@Pending" ../../../src/features | wc -l | xargs` + pending=`grep -r -h "@Pending" ../../../src/features -A 2 | grep -v -- "^--$" | sed "s/@Pending//g" | awk '{$1=$1};1' | sed "/^@/s/ /./g;/^#/s/ /./g" | xargs | sed "s/@[^ ]*/\n&/g" | sed "s/ /./"` + echo " + env=${NETWORK} + browser=${BROWSER} + tags=${TAGS} + platform=Windows + Pending.Count=${pendingCount} + @Pending.Scenarios= + ${pending} + " > environment.properties + - name: Publish artifacts (logs, reports, screenshots) + uses: actions/upload-artifact@v3 + if: always() + with: + name: test-artifacts + path: | + ./packages/e2e-tests/screenshots + ./packages/e2e-tests/logs + ./packages/e2e-tests/reports + retention-days: 10 + outputs: + job-status: ${{ job.status }} + + publish-reports-linux: + if: always() + needs: run-tests-windows + runs-on: ubuntu-20.04 + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Download reports + uses: actions/download-artifact@v3 + with: + name: test-artifacts + path: ./packages/e2e-tests + - name: Publish allure report to S3 + uses: andrcuns/allure-publish-action@v1.0.1 + if: always() + env: + GITHUB_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AWS_ACCESS_KEY_ID: ${{ secrets.E2E_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.E2E_AWS_ACCESS_KEY }} + with: + storageType: s3 + resultsGlob: './packages/e2e-tests/reports/allure/results/*' + bucket: lightwallet + prefix: 'windows/${BROWSER}/${RUN}' + copyLatest: true + ignoreMissingResults: true + - name: Add link to summary + if: always() + run: | + echo "TEST RESULTS:" + echo "https://${{ secrets.E2E_REPORTS_USER }}:${{ secrets.E2E_REPORTS_PASSWORD }}@${{ secrets.E2E_REPORTS_URL }}/windows/${{ env.BROWSER }}/${{ env.RUN }}/index.html | tags: ${{ env.TAGS }} | browser: ${{ env.BROWSER }} | network: ${{ env.NETWORK }} | platform: windows" >> $GITHUB_STEP_SUMMARY + - name: Slack Notification + uses: rtCamp/action-slack-notify@v2.2.0 + if: always() + env: + SLACK_COLOR: ${{ needs.run-tests-windows.outputs.job-status }} + SLACK_ICON_EMOJI: ':lace:' + SLACK_MESSAGE: 'https://${{ secrets.E2E_REPORTS_USER }}:${{ secrets.E2E_REPORTS_PASSWORD }}@${{ secrets.E2E_REPORTS_URL }}/windows/${{ env.BROWSER }}/${{ env.RUN }}/index.html | tags: ${{ env.TAGS }} | browser: ${{ env.BROWSER }} | network: ${{ env.NETWORK }} | platform: windows' + SLACK_TITLE: 'Test automation results :rocket:' + SLACK_USERNAME: lace-qa-bot + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} diff --git a/.github/workflows/git-checks.yml b/.github/workflows/git-checks.yml new file mode 100644 index 0000000000..730cc58435 --- /dev/null +++ b/.github/workflows/git-checks.yml @@ -0,0 +1,12 @@ +name: Git Checks + +on: [pull_request] + +jobs: + block-fixup: + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v3 + - name: Block Fixup Commit Merge + uses: 13rac1/block-fixup-merge-action@v2.0.0 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000000..aedba0fe43 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,15 @@ +name: 'Label PRs' +on: + - pull_request_target + +jobs: + triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v4 + with: + repo-token: '${{ secrets.GITHUB_TOKEN }}' + sync-labels: true diff --git a/.github/workflows/packages-staking.yml b/.github/workflows/packages-staking.yml new file mode 100644 index 0000000000..451d21322a --- /dev/null +++ b/.github/workflows/packages-staking.yml @@ -0,0 +1,66 @@ +name: packages/staking + +# Triggering this workflow: +# 1. Push to main branch +# 2. Pushing to Pull Request with "staking" label +# 3. Adding "staking" label to Pull Request + +on: + pull_request: + push: + branches: + - main + +jobs: + build_staking: + name: Build Staking Center + runs-on: ubuntu-22.04 + container: mcr.microsoft.com/playwright:v1.32.2-jammy + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'staking') + steps: + - name: Setup Build Essential + run: apt-get update && apt-get install build-essential -y + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version-file: .nvmrc + cache: 'yarn' + - name: Node modules cache + uses: actions/cache@v3 + with: + path: | + node_modules + **/node_modules + key: ${{ runner.os }}-${{ hashFiles('yarn.lock') }} + - name: Install dependencies + run: yarn install --frozen-lockfile --non-interactive + - name: Check for linter issues + run: yarn workspace @lace/staking lint + - name: Run tests + run: yarn workspace @lace/staking test:unit --coverage + - name: Upload test coverage artifacts + uses: actions/upload-artifact@v3 + with: + name: staking-coverage + path: packages/staking/coverage + - name: Build Staking dist + run: yarn workspace @lace/staking build + - name: Build Ladle + run: yarn workspace @lace/staking story:build + - name: Upload Ladle artifacts + uses: actions/upload-artifact@v3 + with: + name: staking-ladle + path: packages/staking/build + - name: Run visual regression + continue-on-error: true + run: yarn workspace @lace/staking test:vr + - name: Upload visual regression + uses: actions/upload-artifact@v3 + with: + name: staking-visual-regression + path: packages/staking/.lostpixel diff --git a/.github/workflows/post-integration.yml b/.github/workflows/post-integration.yml new file mode 100644 index 0000000000..ccdea7d04b --- /dev/null +++ b/.github/workflows/post-integration.yml @@ -0,0 +1,34 @@ +name: Post-integration + +on: + push: + branches: + - main + +jobs: + publish: + runs-on: ubuntu-20.04 + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + - name: Node modules cache + uses: actions/cache@v3 + with: + path: | + node_modules + **/node_modules + key: ${{ runner.os }}-${{ hashFiles('yarn.lock') }} + - name: Install dependencies + run: yarn install --frozen-lockfile --non-interactive --loglevel=error + - name: Build + run: yarn browser build + - name: Upload build + uses: actions/upload-artifact@v3 + with: + name: lightwallet + path: apps/browser-extension-wallet/dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..4d41416830 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: Create Release + +on: + workflow_dispatch: + inputs: + app: + description: 'Which app to release (extension)' + required: true + type: choice + options: + - extension + version: + description: 'The version to release' + required: true + +run-name: 'Create Release | app: ${{ inputs.app }} | version: ${{ inputs.version }}' + +jobs: + create_release: + runs-on: ubuntu-20.04 + + steps: + - name: Checkout main branch + uses: actions/checkout@v3 + with: + ref: main + + - name: Create release branch + run: git checkout -b release/v${{ inputs.version }} + + - name: Push branch + run: git push --set-upstream origin release/v${{ inputs.version }} + + - name: Bump version in manifest files + if: inputs.app == 'extension' + run: | + sed -i "s/\"version\": \"[0-9.]*\"/\"version\": \"${{ inputs.version }}\"/g" apps/browser-extension-wallet/manifest.json + + - name: Create pull request + uses: peter-evans/create-pull-request@v4 + with: + commit-message: 'chore(${{ inputs.app }}): bump version to ${{ inputs.version }}' + title: Release version ${{ inputs.version }} for ${{ inputs.app }} + body: | + This pull request is to release version ${{ inputs.version }} for ${{ inputs.app }} app. + branch: release/v${{ inputs.version }} + base: main + labels: release diff --git a/.github/workflows/safari-ci.yml b/.github/workflows/safari-ci.yml new file mode 100644 index 0000000000..097a5222ce --- /dev/null +++ b/.github/workflows/safari-ci.yml @@ -0,0 +1,34 @@ +name: Safari Extension Build + +on: + workflow_run: + workflows: [CI] + types: [completed] + +jobs: + build: + if: ${{ contains(github.event.pull_request.labels.*.name, 'run-safari-build') }} + name: Build for Safari + runs-on: macos-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Download Lace dist artifacts + uses: actions/download-artifact@v3 + with: + name: lace + path: apps/browser-extension-wallet/dist + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Add executable rights to extension conversion script + run: chmod +x ./packages/e2e-tests/tools/convertChromeExtToSafari.sh + - name: Convert Chrome extension to Safari + run: packages/e2e-tests/tools/convertChromeExtToSafari.sh + shell: bash + - name: Upload unsigned Safari build + uses: actions/upload-artifact@v3 + with: + name: lace-safari + path: packages/e2e-tests/wallet-extension-safari-build/Lace/wallet-extension-safari-build/extension-build/Build/Products/Release diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml new file mode 100644 index 0000000000..23a8d58344 --- /dev/null +++ b/.github/workflows/smoke-tests.yml @@ -0,0 +1,102 @@ +name: Smoke Tests + +on: + pull_request: + branches: + - main + workflow_dispatch: + +env: + TAGS: '@Smoke and @Testnet and not @Pending' + BROWSER: 'chrome' + RUN: ${{ github.run_number }} + DISPLAY: ':99.0' + +jobs: + smokeTests: + name: Smoke Tests + runs-on: ubuntu-20.04 + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Decrypt test data + working-directory: ./packages/e2e-tests + run: ./decrypt_secret.sh + env: + WALLET_1_PASSWORD: ${{ secrets.WALLET_PASSWORD_TESTNET }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + - name: Node modules cache + uses: actions/cache@v3 + with: + path: | + node_modules + **/node_modules + key: ${{ runner.os }}-${{ hashFiles('yarn.lock') }} + - name: Install dependencies + run: yarn install --check-cache --frozen-lockfile --non-interactive --loglevel=error + - name: Build dist version of Lace + uses: ./.github/shared/build + with: + LACE_EXTENSION_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }} + - name: Start XVFB + run: | + Xvfb :99 & + - name: Start Chrome driver + working-directory: /usr/local/share/chrome_driver + run: | + if [ ${BROWSER} == "chrome" ]; then + ./chromedriver -port=4444 & + else + echo "Skipping start of ChromeDriver" + fi + - name: Execute E2E tests + id: e2e-tests + working-directory: ./packages/e2e-tests + env: + WALLET_1_PASSWORD: ${{ secrets.WALLET_PASSWORD_TESTNET }} + TEST_DAPP_URL: ${{ secrets.TEST_DAPP_URL }} + run: ./node_modules/.bin/wdio run wdio.conf.${BROWSER}.ts --cucumberOpts.tagExpression="@Smoke and not @Pending" + - name: Create allure properties + if: always() + working-directory: ./packages/e2e-tests/reports/allure/results + run: | + pendingCount=`grep -r "@Pending" ../../../src/features | grep "@Smoke" | wc -l | xargs` + pending=`grep -r -h "@Pending" ../../../src/features -A 2 | grep "@Smoke" -A 2 | grep -v -- "^--$" | sed "s/@Pending//g" | awk '{$1=$1};1' | sed "/^@/s/ /./g;/^#/s/ /./g" | xargs | sed "s/@[^ ]*/\n&/g" | sed "s/ /./"` + echo " + browser=${BROWSER} + tags=${TAGS} + platform=Linux + Pending.and.Smoke.Count=${pendingCount} + @Pending.and.Smoke.Scenarios= + ${pending} + " > environment.properties + - name: Publish allure report to S3 + uses: andrcuns/allure-publish-action@v2.2.3 + if: always() + env: + GITHUB_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AWS_ACCESS_KEY_ID: ${{ secrets.E2E_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.E2E_AWS_ACCESS_KEY }} + with: + storageType: s3 + resultsGlob: './packages/e2e-tests/reports/allure/results' + bucket: lightwallet + prefix: 'linux/${BROWSER}/${RUN}' + copyLatest: true + ignoreMissingResults: true + updatePr: description + baseUrl: 'https://${{ secrets.E2E_REPORTS_USER }}:${{ secrets.E2E_REPORTS_PASSWORD }}@${{ secrets.E2E_REPORTS_URL }}' + - name: Publish artifacts (logs, reports, screenshots) + uses: actions/upload-artifact@v3 + if: always() + with: + name: test-artifacts + path: | + ./packages/e2e-tests/screenshots + ./packages/e2e-tests/logs + ./packages/e2e-tests/reports + retention-days: 5 diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 0000000000..e2ab7b9c8d --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -0,0 +1,88 @@ +on: + push: + branches: + - main +name: SonarQube scanner +jobs: + sonarQubeTrigger: + name: SonarQube + runs-on: ubuntu-latest + env: + SONARCLOUD_URL: https://sonar.atixlabs.com/ + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + - name: Node modules cache + uses: actions/cache@v3 + with: + path: | + node_modules + **/node_modules + key: ${{ runner.os }}-${{ hashFiles('yarn.lock') }} + - name: Install dependencies + run: yarn install --frozen-lockfile --non-interactive --loglevel=error + - name: Build + env: + NODE_OPTIONS: '--max_old_space_size=8192 --openssl-legacy-provider' + run: yarn build + - name: test + run: yarn test:coverage + - name: Sonar - @lace/cardano + uses: sonarsource/sonarcloud-github-action@master + with: + projectBaseDir: packages/cardano + args: > + -Dsonar.projectKey=iohk-lightwallet:cardano + -Dsonar.language=ts + -Dsonar.sources=src + -Dsonar.sourceEncoding=UTF-8 + -Dsonar.exclusions=src/**/*.test.ts,src/**/*.test.tsx + -Dsonar.test.inclusions=src/**/*.test.ts,src/**/*.test.tsx + -Dsonar.typescript.lcov.reportPaths=coverage/lcov.info + -Dsonar.coverage.exclusions=src/**/*.test.ts,src/**/*.test.tsx,src/**/*.mock.ts,node_modules/* + - name: Sonar - @lace/core + uses: sonarsource/sonarcloud-github-action@master + with: + projectBaseDir: packages/core + args: > + -Dsonar.projectKey=iohk-lightwallet:core + -Dsonar.language=ts + -Dsonar.sources=src + -Dsonar.sourceEncoding=UTF-8 + -Dsonar.exclusions=src/**/*.test.ts,src/**/*.test.tsx + -Dsonar.test.inclusions=src/**/*.test.ts,src/**/*.test.tsx + -Dsonar.typescript.lcov.reportPaths=coverage/lcov.info + -Dsonar.coverage.exclusions=src/**/*.test.ts,src/**/*.test.tsx,src/**/*.mock.ts,node_modules/* + - name: Sonar - @lace/common + uses: sonarsource/sonarcloud-github-action@master + with: + projectBaseDir: packages/common + args: > + -Dsonar.projectKey=iohk-lightwallet:common + -Dsonar.language=ts + -Dsonar.sources=src + -Dsonar.sourceEncoding=UTF-8 + -Dsonar.exclusions=src/**/*.test.ts,src/**/*.test.tsx + -Dsonar.test.inclusions=src/**/*.test.ts,src/**/*.test.tsx + -Dsonar.typescript.lcov.reportPaths=coverage/lcov.info + -Dsonar.coverage.exclusions=src/**/*.test.ts,src/**/*.test.tsx,src/**/*.mock.ts,node_modules/* + - name: Sonar - @light-wallet/apps/browser-extension-wallet + uses: sonarsource/sonarcloud-github-action@master + with: + projectBaseDir: apps/browser-extension-wallet + args: > + -Dsonar.projectKey=iohk-lightwallet:browser-extension + -Dsonar.language=ts + -Dsonar.sources=src + -Dsonar.sourceEncoding=UTF-8 + -Dsonar.exclusions=src/**/*.test.ts,src/**/*.test.tsx + -Dsonar.test.inclusions=src/**/*.test.ts,src/**/*.test.tsx + -Dsonar.typescript.lcov.reportPaths=coverage/lcov.info + -Dsonar.coverage.exclusions=src/**/*.test.ts,src/**/*.test.tsx,src/**/*.mock.ts,node_modules/* diff --git a/.github/workflows/ui-toolkit-chromatic.yml b/.github/workflows/ui-toolkit-chromatic.yml new file mode 100644 index 0000000000..733aa6f4ba --- /dev/null +++ b/.github/workflows/ui-toolkit-chromatic.yml @@ -0,0 +1,66 @@ +name: Lace UI Toolkit + +on: + pull_request: + paths: + - packages/ui/** + push: + paths: + - packages/ui/** + branches: + - main + +jobs: + chromatic-deployment: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: 🧰 Use Node.js + uses: actions/setup-node@v3 + with: + node-version-file: .nvmrc + cache: 'yarn' + + - name: 📝 Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT + + - name: 📝 Cache + uses: actions/cache@v3 + id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: 💽 Install dependencies + run: yarn --frozen-lockfile + + - name: 🧑‍🔬 Linter + working-directory: ./packages/ui + run: yarn lint + + - name: 👩‍🔬 Tests + working-directory: ./packages/ui + run: yarn test-storybook:ci + + - name: 🌍 Publish to Chromatic + if: github.ref != 'refs/heads/main' + uses: chromaui/action@v1 + with: + projectToken: ${{ secrets.CHROMATIC_LACE_UI_TOOLKIT_TOKEN }} + workingDir: ./packages/ui + buildScriptName: build-storybook + + - name: 🌍 Publish to Chromatic and auto accept changes + if: github.ref == 'refs/heads/main' + uses: chromaui/action@v1 + with: + projectToken: ${{ secrets.CHROMATIC_LACE_UI_TOOLKIT_TOKEN }} + autoAcceptChanges: true + workingDir: ./packages/ui diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..22a2fb83ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +.idea +.env +.history +.lh +*.crx +*.d.ts +!declaration.d.ts +*dist/ +*demo/ +*node_modules/ +*yarn-error.log +storybook-static +build-storybook.log +.DS_Store +coverage +.rollup.cache +.tool-versions + +# PRJ Spec +/.cache diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000000..816c57f045 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,5 @@ +{ + "MD013": { + "line_length": 120 + } +} diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..e44a38e080 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v18.12.1 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000000..73bacd5ed2 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "printWidth": 120, + "trailingComma": "none", + "arrowParens": "always" +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..13d1bef412 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright © 2021 IOHK + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000000..21e4aac2ca --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Lace + +The Lace monorepo. + +# Yarn Workflows + +## Structure + +### Apps + +- [browser-extension-wallet] + +### Packages + +- [common] +- [core] +- [cardano] + +## Some commands + +There are few root dev commands available: + +- `build` +- `build-deps` - build only dependencies of a given app +- `watch` - build and watch +- `watch-deps` - build and watch only dependencies of a given app + +You can use them while working either with the `browser` or `desktop` app the following way: + +```console +yarn [app] [command] +``` + +The `yarn [app]` script runs the subsequent command in the context of particular application. That means, it sets +the appropriate env variable used by all involved build processes. + +**Examples:** + +- `yarn desktop watch` runs the `watch` command for all dependent workspaces of the [desktop] app and for the [desktop] + app itself. +- `yarn browser build-deps` builds all dependent workspaces of the [browser-extension-wallet] app except the app itself. +- `yarn browser [any command supported by yarn]` command you provide will run in the [browser-extension-wallet] + context. For example: + - `yarn desktop workspace @light-wallet/core build` runs the `build` command **only for the @light-wallet/[core] + package**, but in + the context of the `desktop`\*. Notice the `workspace @light-wallet/core build` is a yarn' syntax + +\* the `@light-wallet/core` package will be built specifically for the `desktop` app + +[browser-extension-wallet]: ./apps/browser-extension-wallet +[common]: ./packages/common +[core]: ./packages/core +[cardano]: ./packages/cardano + +## Audit + +Lace has been independently audited and manually verified by external auditor, [FYEO](https://www.fyeo.io/), so the Lace team can improve code quality and security – giving you greater peace of mind. You can view the full report at [lace.io/lace-audit-report](https://lace.io/lace-audit-report) diff --git a/apps/browser-extension-wallet/.env.defaults b/apps/browser-extension-wallet/.env.defaults new file mode 100644 index 0000000000..eccfb95a82 --- /dev/null +++ b/apps/browser-extension-wallet/.env.defaults @@ -0,0 +1,51 @@ +# Lace Defaults +WALLET_NAME=lace +DEFAULT_CHAIN=Mainnet +WALLET_SYNC_TIMEOUT_IN_SEC=60 +WALLET_INTERVAL_IN_SEC=30 +DROP_CONSOLE_IN_PRODUCTION=false +AVAILABLE_CHAINS=Preprod,Preview,Mainnet +ADA_PRICE_POLLING_IN_SEC=60 +SAVED_PRICE_DURATION_IN_MINUTES=720 + +# Feature Flags +USE_PASSWORD_VERIFICATION=false +USE_DAPP_CONNECTOR=true +USE_TREZOR_HW=false +USE_TOKEN_PRICING=true +USE_DIFFERENT_MNEMONIC_LENGTHS=true +USE_NFT_FOLDERS=false +USE_MULTI_CURRENCY=true +USE_HIDE_MY_BALANCE=false +USE_MULTI_DELEGATION_STAKING=false + +# In App URLs +CATALYST_GOOGLE_PLAY_URL=https://play.google.com/store/apps/details?id=io.iohk.vitvoting +CATALYST_APP_STORE_URL=https://apps.apple.com/fr/app/catalyst-voting/id1517473397?l=en +TWITTER_URL=https://twitter.com/lace_io +YOUTUBE_URL=https://www.youtube.com/c/Laceio +MEDIUM_URL=https://medium.com/@lace_wallet +GITHUB_URL=https://github.com/input-output-hk +DISCORD_URL=https://discord.gg/inputoutput +WEBSITE_URL=https://www.lace.io +EMAIL_ADDRESS=lace@iohk.io +HELP_URL=https://iohk.zendesk.com/hc/en-us/requests/new +FAQ_URL=https://www.lace.io/faq +PRIVACY_POLICY_URL=https://www.lace.io/iog-privacy-policy.pdf +COOKIE_POLICY_URL=https://www.lace.io/lace-cookie-policy.pdf +TERMS_OF_USE_URL=https://www.lace.io/lace-terms-of-use.pdf +MATOMO_API_ENDPOINT=https://matomo.cw.iog.io/matomo.php + +# Cardano Services +CARDANO_SERVICES_URL_MAINNET=https://backend.live-mainnet.eks.lw.iog.io +CARDANO_SERVICES_URL_PREPROD=https://backend.live-preprod.eks.lw.iog.io +CARDANO_SERVICES_URL_PREVIEW=https://backend.live-preview.eks.lw.iog.io + +# Explorer URLs +CEXPLORER_URL_MAINNET=https://cexplorer.io +CEXPLORER_URL_PREVIEW=https://preview.cexplorer.io +CEXPLORER_URL_PREPROD=https://preprod.cexplorer.io +CEXPLORER_URL_TESTNET=https://testnet.cexplorer.io + +# Manifest.json +LACE_EXTENSION_KEY=gafhhkghbfjjkeiendhlofajokpaflmk diff --git a/apps/browser-extension-wallet/.env.example b/apps/browser-extension-wallet/.env.example new file mode 100644 index 0000000000..eccfb95a82 --- /dev/null +++ b/apps/browser-extension-wallet/.env.example @@ -0,0 +1,51 @@ +# Lace Defaults +WALLET_NAME=lace +DEFAULT_CHAIN=Mainnet +WALLET_SYNC_TIMEOUT_IN_SEC=60 +WALLET_INTERVAL_IN_SEC=30 +DROP_CONSOLE_IN_PRODUCTION=false +AVAILABLE_CHAINS=Preprod,Preview,Mainnet +ADA_PRICE_POLLING_IN_SEC=60 +SAVED_PRICE_DURATION_IN_MINUTES=720 + +# Feature Flags +USE_PASSWORD_VERIFICATION=false +USE_DAPP_CONNECTOR=true +USE_TREZOR_HW=false +USE_TOKEN_PRICING=true +USE_DIFFERENT_MNEMONIC_LENGTHS=true +USE_NFT_FOLDERS=false +USE_MULTI_CURRENCY=true +USE_HIDE_MY_BALANCE=false +USE_MULTI_DELEGATION_STAKING=false + +# In App URLs +CATALYST_GOOGLE_PLAY_URL=https://play.google.com/store/apps/details?id=io.iohk.vitvoting +CATALYST_APP_STORE_URL=https://apps.apple.com/fr/app/catalyst-voting/id1517473397?l=en +TWITTER_URL=https://twitter.com/lace_io +YOUTUBE_URL=https://www.youtube.com/c/Laceio +MEDIUM_URL=https://medium.com/@lace_wallet +GITHUB_URL=https://github.com/input-output-hk +DISCORD_URL=https://discord.gg/inputoutput +WEBSITE_URL=https://www.lace.io +EMAIL_ADDRESS=lace@iohk.io +HELP_URL=https://iohk.zendesk.com/hc/en-us/requests/new +FAQ_URL=https://www.lace.io/faq +PRIVACY_POLICY_URL=https://www.lace.io/iog-privacy-policy.pdf +COOKIE_POLICY_URL=https://www.lace.io/lace-cookie-policy.pdf +TERMS_OF_USE_URL=https://www.lace.io/lace-terms-of-use.pdf +MATOMO_API_ENDPOINT=https://matomo.cw.iog.io/matomo.php + +# Cardano Services +CARDANO_SERVICES_URL_MAINNET=https://backend.live-mainnet.eks.lw.iog.io +CARDANO_SERVICES_URL_PREPROD=https://backend.live-preprod.eks.lw.iog.io +CARDANO_SERVICES_URL_PREVIEW=https://backend.live-preview.eks.lw.iog.io + +# Explorer URLs +CEXPLORER_URL_MAINNET=https://cexplorer.io +CEXPLORER_URL_PREVIEW=https://preview.cexplorer.io +CEXPLORER_URL_PREPROD=https://preprod.cexplorer.io +CEXPLORER_URL_TESTNET=https://testnet.cexplorer.io + +# Manifest.json +LACE_EXTENSION_KEY=gafhhkghbfjjkeiendhlofajokpaflmk diff --git a/apps/browser-extension-wallet/.gitignore b/apps/browser-extension-wallet/.gitignore new file mode 100755 index 0000000000..4e58dfa1b0 --- /dev/null +++ b/apps/browser-extension-wallet/.gitignore @@ -0,0 +1,9 @@ +.cache/ +coverage/ +*.log +dist +node_modules + +# Do not ignore the following +!src/typings/* +!test/types/* \ No newline at end of file diff --git a/apps/browser-extension-wallet/.nvmrc b/apps/browser-extension-wallet/.nvmrc new file mode 100644 index 0000000000..e44a38e080 --- /dev/null +++ b/apps/browser-extension-wallet/.nvmrc @@ -0,0 +1 @@ +v18.12.1 diff --git a/apps/browser-extension-wallet/.prettierignore b/apps/browser-extension-wallet/.prettierignore new file mode 100644 index 0000000000..de4d1f007d --- /dev/null +++ b/apps/browser-extension-wallet/.prettierignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/apps/browser-extension-wallet/LICENSE b/apps/browser-extension-wallet/LICENSE new file mode 100644 index 0000000000..13d1bef412 --- /dev/null +++ b/apps/browser-extension-wallet/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright © 2021 IOHK + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/apps/browser-extension-wallet/NOTICE b/apps/browser-extension-wallet/NOTICE new file mode 100644 index 0000000000..e33ce8c987 --- /dev/null +++ b/apps/browser-extension-wallet/NOTICE @@ -0,0 +1,5 @@ +Copyright 2021 IOHK + +Licensed under the Apache License, Version 2.0 (the "License”). You may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.txt + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. \ No newline at end of file diff --git a/apps/browser-extension-wallet/README.md b/apps/browser-extension-wallet/README.md new file mode 100644 index 0000000000..8e62621d88 --- /dev/null +++ b/apps/browser-extension-wallet/README.md @@ -0,0 +1,75 @@ +# Light Wallet | Apps | Browser Extension + +A fully capable wallet packaged as browser extensions for Chrome, Firefox, Edge, and Safari. The +app imports all UI and wallet functionality from the [core] and [cardano] packages. + +[core]: ../../packages/core +[cardano]: ../../packages/cardano + +## Building and Running + +### Configuration + +Default values that are to be stored in repository are provided in `.env.defaults` file. + +The webpack plugin that is used to collect settings is configured +to ensure that all settings defined in example file are defined +in the files `.env.defaults`, `.env` or as environment variables + +### Development + +```sh +yarn dev +``` + +### Production build + +```sh +yarn build +``` + +#### Chrome + +- Open the Extensions settings (Wrench button > Tools > Extensions or navigate to chrome://extensions. +- On the Extensions settings tab, click the "Developer Mode" checkbox. +- Click the now-visible "Load unpacked extension..." button and select the `apps/browser-extension-wallet/dist` folder + +#### Firefox + +- Navigate to about:debugging#addons. +- On the Add-ons tab, click the "Enable add-on debugging" checkbox. +- Click "Load Temporary Add-on" button and select the `manifest.json` under `apps/browser-extension-wallet/dist` folder + +#### Safari + +Prerequisites: + +- XCode command line tools and XCode (install cli tools first / use 'sudo xcode-select --reset' so correct path is set). +- Safari with enabled Develop menu (`Safari>Settings>Advanced>Show develop menu in menu bar`) and allowed unsigned + extensions (`Develop>Allow Unsigned Extensions`). + +Steps: + +- Build Lace with `yarn build` in the main directory. +- Open `/packages/e2e-tests/` directory in your terminal +- Run `yarn safari:build`. +- Open Safari and make sure **Unsigned Extensions** are allowed (it turns off each Safari exit). +- Run `yarn safari:open`. +- Click `Quit and Open Safari Settings...`. +- Enable Lace with the tick next to extension's name. + +### Testing + +#### Wallaby.js (Optional) Streamline your testing XP + + + +Wallaby.js is an IDE plugin that streamlines Test Driven Develompent by giving realtime testing feedback +within the IDE right where you write your code. This way you don't have to re-run test cases all the time +(and wait for the result) but Wallaby.js figures what changes you made, which test cases could be affected +and re-runs only these in realtime + giving you extra capabilities to debug and log values right in your IDE. + +To make it work for the browser extension app you need to choose the following config options for Wallaby: + +1. Configuration type: "Configuration File" +2. For the path to the Configuration File: `ENTER_YOUR_PATH_TO/lace/apps/browser-extension-wallet/wallaby.js` diff --git a/apps/browser-extension-wallet/manifest.json b/apps/browser-extension-wallet/manifest.json new file mode 100644 index 0000000000..49789733b9 --- /dev/null +++ b/apps/browser-extension-wallet/manifest.json @@ -0,0 +1,41 @@ +{ + "name": "Lace", + "description": "One fast, accessible, and secure platform for digital assets, DApps, NFTs, and DeFi.", + "version": "1.1.0", + "manifest_version": 3, + "key": "$LACE_EXTENSION_KEY", + "icons": { + "16": "icon16.png", + "32": "icon32.png", + "48": "icon48.png", + "128": "icon128.png" + }, + "background": { + "service_worker": "./js/background.js" + }, + "action": { + "default_popup": "popup.html" + }, + "permissions": ["webRequest", "storage", "tabs", "unlimitedStorage"], + "host_permissions": [""], + "content_security_policy": { + "extension_pages": "default-src 'self' $LOCALHOST_DEFAULT_SRC; frame-src https://connect.trezor.io/; script-src 'self' 'wasm-unsafe-eval' $LOCALHOST_SCRIPT_SRC; font-src 'self' https://use.typekit.net; object-src 'self'; connect-src $CARDANO_SERVICES_URLS https://api.coingecko.com https://analyticsv2.muesliswap.com $LOCALHOST_CONNECT_SRC https://matomo.cw.iog.io/ https://use.typekit.net; style-src * 'unsafe-inline'; img-src * data:;" + }, + "content_scripts": [ + { + "matches": ["http://*/*", "https://*/*", "file://*/*"], + "js": ["./js/content.js"], + "run_at": "document_start" + }, + { + "matches": ["*://connect.trezor.io/*/popup.html"], + "js": ["js/trezor-content-script.js"] + } + ], + "web_accessible_resources": [ + { + "resources": ["/js/inject.js"], + "matches": ["http://*/*", "https://*/*", ""] + } + ] +} diff --git a/apps/browser-extension-wallet/package.json b/apps/browser-extension-wallet/package.json new file mode 100644 index 0000000000..4187ffddc3 --- /dev/null +++ b/apps/browser-extension-wallet/package.json @@ -0,0 +1,107 @@ +{ + "name": "@lace/browser-extension-wallet", + "version": "1.1.0", + "description": "A fully capable wallet packaged as browser extensions for Chrome, Firefox, and Edge", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "scripts": { + "build": "NODE_OPTIONS='--openssl-legacy-provider' rm -rf dist & webpack --config webpack.sw.prod.js --progress & webpack --config webpack.app.prod.js --progress", + "build:dev": "NODE_OPTIONS='--openssl-legacy-provider' rm -rf dist & webpack --config webpack.sw.dev.js --progress & webpack --config webpack.app.dev.js --progress", + "watch": "NODE_OPTIONS='--openssl-legacy-provider' rm -rf dist & webpack --config webpack.sw.dev.js --progress --watch & webpack --config webpack.app.dev.js --progress --watch", + "cleanup": "shx rm -rf dist node_modules", + "lint": "cd ../.. && yarn extension:lint", + "prepack": "yarn build", + "dev": "NODE_OPTIONS='--openssl-legacy-provider' rm -rf dist & webpack --config webpack.sw.dev.js --progress --watch & webpack serve --config webpack.app.dev.js --env RUN_DEV_SERVER=true", + "test:e2e": "shx echo \"No e2e tests on this app yet!\"", + "test": "jest --config test/jest.config.js", + "test:coverage": "yarn test --coverage", + "prettier": "prettier --write .", + "prepare": "ts-patch install -s" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/input-output-hk/lace.git" + }, + "author": "IOHK", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/input-output-hk/lace/issues" + }, + "homepage": "https://github.com/input-output-hk/lace/blob/master/apps/browser-extension-wallet/README.md", + "dependencies": { + "@ant-design/icons": "^4.7.0", + "@cardano-sdk/core": "~0.11.0", + "@cardano-sdk/dapp-connector": "~0.9.0", + "@cardano-sdk/input-selection": "~0.9.1", + "@cardano-sdk/util": "~0.9.0", + "@cardano-sdk/wallet": "~0.11.0", + "@cardano-sdk/web-extension": "~0.9.0", + "@emurgo/cip14-js": "~3.0.1", + "@koralabs/handles-public-api-interfaces": "^1.6.6", + "@lace/cardano": "0.1.0", + "@lace/common": "0.1.0", + "@lace/core": "0.1.0", + "@lace/staking": "0.1.0", + "@react-rxjs/core": "^0.9.8", + "@react-rxjs/utils": "^0.9.5", + "@vespaiach/axios-fetch-adapter": "^0.3.0", + "antd": "^4.17.3", + "are-you-es5": "^2.1.2", + "axios": "0.21.4", + "bignumber.js": "9.0.1", + "bip39": "^3.0.4", + "blake2b-no-wasm": "2.1.4", + "buffer": "6.0.3", + "classnames": "2.3.1", + "dayjs": "1.10.7", + "dexie": "3.2.0-rc.2", + "dexie-react-hooks": "1.0.7", + "graphql-tag": "2.12.5", + "i18next": "20.4.0", + "jdenticon": "3.1.0", + "lodash": "4.17.21", + "matomo-tracker": "^2.2.4", + "p-debounce": "^4.0.0", + "pluralize": "^8.0.0", + "process": "^0.11.10", + "react": "17.0.2", + "react-dom": "17.0.2", + "react-i18next": "11.11.4", + "react-lottie": "^1.2.3", + "react-router": "5.2.0", + "react-router-dom": "5.2.0", + "readable-stream": "^3.6.0", + "rxjs": "7.4.0", + "webextension-polyfill": "0.8.0", + "zustand": "3.5.14", + "intersection-observer-polyfill": "0.1.0" + }, + "devDependencies": { + "@dcspark/cardano-multiplatform-lib-asmjs": "^3.1.0", + "@emurgo/cardano-message-signing-asmjs": "1.0.1", + "@types/dotenv-webpack": "7.0.3", + "@types/pluralize": "^0.0.29", + "@types/react-lottie": "^1.2.6", + "@types/uuid": "^8.3.4", + "@types/w3c-web-hid": "^1.0.3", + "@types/webextension-polyfill": "0.8.0", + "dotenv-defaults": "5.0.2", + "dotenv-webpack": "8.0.1", + "eslint-plugin-prettier": "^4.0.0", + "fake-indexeddb": "3.1.3", + "fork-ts-checker-webpack-plugin": "^7.2.1", + "jest-webextension-mock": "^3.7.19", + "webassembly-loader-sw": "^1.1.0", + "tsconfig-paths-webpack-plugin": "3.5.2" + }, + "directories": { + "lib": "src", + "test": "test" + }, + "files": [ + "dist", + "LICENSE", + "NOTICE", + "README.md" + ] +} diff --git a/apps/browser-extension-wallet/src/api/__tests__/block-transformer.test.ts b/apps/browser-extension-wallet/src/api/__tests__/block-transformer.test.ts new file mode 100644 index 0000000000..b3b53d6f0d --- /dev/null +++ b/apps/browser-extension-wallet/src/api/__tests__/block-transformer.test.ts @@ -0,0 +1,12 @@ +import '@testing-library/jest-dom'; +import { blockTransformer } from '../transformers'; +import { blockMock, formatBlockMock } from '../../utils/mocks/test-helpers'; + +describe('Testing blockTransformer function', () => { + test('should format block information', () => { + const result = blockTransformer(blockMock); + expect(result).toEqual(formatBlockMock); + }); + + test.todo('blockTransformer > test more conditions'); +}); diff --git a/apps/browser-extension-wallet/src/api/__tests__/input-output-transformer.test.ts b/apps/browser-extension-wallet/src/api/__tests__/input-output-transformer.test.ts new file mode 100644 index 0000000000..15fc4657af --- /dev/null +++ b/apps/browser-extension-wallet/src/api/__tests__/input-output-transformer.test.ts @@ -0,0 +1,72 @@ +/* eslint-disable no-magic-numbers */ +import '@testing-library/jest-dom'; +import { inputOutputTransformer } from '../transformers'; +import { mockAsset, mockPrices } from '../../utils/mocks/test-helpers'; +import { Wallet } from '@lace/cardano'; +import { defaultCurrency } from '@providers/currency/constants'; + +const output = { + address: Wallet.Cardano.PaymentAddress( + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp' + ), + value: { assets: new Map(), coins: BigInt(1_000_000) } +}; + +const input = { + txId: Wallet.Cardano.TransactionId('724a0a88b9470a714fc5bf84daf5851fa259a9b89e1a5453f6f5cd6595ad9827'), + address: Wallet.Cardano.PaymentAddress( + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp' + ), + index: 0, + value: { assets: new Map(), coins: BigInt(3_000_000) } +}; + +describe('Testing inputOutputTransformer function', () => { + test('should format transaction output with no asset list', () => { + const result = inputOutputTransformer( + output, + new Map([[mockAsset.assetId, mockAsset]]), + mockPrices, + defaultCurrency + ); + expect(result.addr).toEqual( + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp' + ); + expect(result.amount).toBe('1.00'); + expect(result.assetList.length).toEqual(0); + }); + + test('should format transaction input with no asset list', () => { + const result = inputOutputTransformer( + input, + new Map([[mockAsset.assetId, mockAsset]]), + mockPrices, + defaultCurrency + ); + expect(result.addr).toEqual( + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp' + ); + expect(result.amount).toBe('3.00'); + expect(result.assetList.length).toEqual(0); + }); + + test('should format transaction input with assets list', () => { + const result = inputOutputTransformer( + { + ...input, + value: { + ...input.value, + assets: new Map([[mockAsset.assetId, BigInt('3000000')]]) + } + }, + new Map([[mockAsset.assetId, mockAsset]]), + mockPrices, + defaultCurrency + ); + expect(result.addr).toEqual( + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp' + ); + expect(result.amount).toBe('3.00'); + expect(result.assetList.length).toEqual(1); + }); +}); diff --git a/apps/browser-extension-wallet/src/api/__tests__/network-info-transformer.test.ts b/apps/browser-extension-wallet/src/api/__tests__/network-info-transformer.test.ts new file mode 100644 index 0000000000..8e274ba91d --- /dev/null +++ b/apps/browser-extension-wallet/src/api/__tests__/network-info-transformer.test.ts @@ -0,0 +1,48 @@ +/* eslint-disable no-magic-numbers */ +import '@testing-library/jest-dom'; +import { networkInfoTransformer } from '../transformers'; +import { Wallet } from '@lace/cardano'; + +const currentEpoch = { + epochNo: Wallet.Cardano.EpochNo(100), + firstSlot: { + slot: Wallet.Cardano.Slot(8_547_194), + date: new Date('2023-03-21 21:45:15') + }, + lastSlot: { + slot: Wallet.Cardano.Slot(8_568_062), + date: new Date('2023-03-26 21:45:15') + } +}; +const lovelaceSupply = { + circulating: BigInt(42_064_399_450_423_723), + total: BigInt(40_267_211_394_073_980) +}; +const stake = { + active: BigInt(1_060_378_314_781_343), + live: BigInt(15_001_884_895_856_815) +}; + +const stakePoolStats = { + qty: { + active: 1, + retired: 0, + retiring: 0 + } +}; + +const totalStakedPercentageResult = + Number.parseInt(((stake.active * BigInt(1000)) / lovelaceSupply.circulating).toString()) / 10; + +describe('Testing networkInfoTransformer function', () => { + test('should format network data', () => { + const result = networkInfoTransformer({ currentEpoch, lovelaceSupply, stake }, stakePoolStats); + expect(result.totalStaked.number).toBe('1.06'); + expect(result.totalStaked.unit).toBe('B'); + expect(result.totalStakedPercentage).toBe(totalStakedPercentageResult); + expect(result.nextEpochIn).toBe(currentEpoch.lastSlot.date); + expect(result.currentEpoch).toBe(currentEpoch.epochNo.toString()); + expect(result.currentEpochIn).toBe(currentEpoch.firstSlot.date); + expect(result.stakePoolsAmount).toBe(stakePoolStats.qty.active.toString()); + }); +}); diff --git a/apps/browser-extension-wallet/src/api/__tests__/token-transformer.test.ts b/apps/browser-extension-wallet/src/api/__tests__/token-transformer.test.ts new file mode 100644 index 0000000000..df1897b895 --- /dev/null +++ b/apps/browser-extension-wallet/src/api/__tests__/token-transformer.test.ts @@ -0,0 +1,59 @@ +import '@testing-library/jest-dom'; +import { tokenTransformer } from '../transformers'; +import { mockAsset, mockPrices } from '../../utils/mocks/test-helpers'; +import { Wallet } from '@lace/cardano'; +import { defaultCurrency } from '@providers/currency/constants'; + +const balance = [ + Wallet.Cardano.AssetId('6b8d07d69639e9413dd637a1a815a7323c69c86abbafb66dbfdb1aa7'), + BigInt('3000000') +] as [Wallet.Cardano.AssetId, bigint]; + +describe('Testing tokenTransformer function', () => { + test('should format token with asset name as name and symbol, with no logo and no fiatBalance', () => { + const result = tokenTransformer(mockAsset, balance, mockPrices, defaultCurrency); + expect(result.id).toBe(balance[0].toString()); + expect(result.amount).toBe(balance[1].toString()); + expect(result.name).toBe(mockAsset.tokenMetadata.name); + expect(result.symbol).toBe(mockAsset.tokenMetadata.name); + expect(result.fiatBalance).toBeUndefined(); + expect(result.logo).toBe(''); + }); + + test('should format token with fingerprint as name and ticker as symbol', () => { + const assetWithNoMetadataName = { ...mockAsset }; + delete assetWithNoMetadataName.tokenMetadata.name; + const result = tokenTransformer(mockAsset, balance, mockPrices, defaultCurrency); + expect(result.name).toBe(mockAsset.fingerprint.toString()); + expect(result.symbol).toBe(mockAsset.tokenMetadata.ticker); + }); + + test('should format token with fingerprint as name and fingerprint with ellipsis format as symbol', () => { + const assetWithNoMetadataNameAndTicker = { ...mockAsset }; + delete assetWithNoMetadataNameAndTicker.tokenMetadata.name; + delete assetWithNoMetadataNameAndTicker.tokenMetadata.ticker; + const result = tokenTransformer(mockAsset, balance, mockPrices, defaultCurrency); + expect(result.name).toBe(mockAsset.fingerprint.toString()); + expect(result.symbol).toBe('asset1cv...h3kcz0'); + }); + + test('should format token with fiatBalance', () => { + const prices = { + ...mockPrices, + tokens: new Map([ + [ + balance[0], + { + id: balance[0].toString(), + priceInAda: 1.2, + priceVariationPercentage24h: 2.9 + } + ] + ]) + }; + const result = tokenTransformer(mockAsset, balance, prices, defaultCurrency); + const tokenPrice = 1.2; + const fiatBalanceResult = tokenPrice * mockPrices.cardano.price * Number(balance[1]); + expect(result.fiatBalance).toBe(`${fiatBalanceResult}.000 USD`); + }); +}); diff --git a/apps/browser-extension-wallet/src/api/__tests__/transform-token-map.test.ts b/apps/browser-extension-wallet/src/api/__tests__/transform-token-map.test.ts new file mode 100644 index 0000000000..3f75db5d10 --- /dev/null +++ b/apps/browser-extension-wallet/src/api/__tests__/transform-token-map.test.ts @@ -0,0 +1,40 @@ +/* eslint-disable no-magic-numbers */ +import '@testing-library/jest-dom'; +import { transformTokenMap } from '../transformers'; +import { mockAsset, mockPrices } from '../../utils/mocks/test-helpers'; +import { Wallet } from '@lace/cardano'; +import { defaultCurrency } from '@providers/currency/constants'; + +const token1Id = mockAsset.assetId; +const token1 = { + balance: [token1Id, BigInt('3000000')] as [Wallet.Cardano.AssetId, bigint], + + info: mockAsset +}; +const token2Id = Wallet.Cardano.AssetId('6ac8ef33b510ec004fe11585f7c5a9f0c07f0c23428ab4f29c1d7d104d454c44'); +const token2 = { + balance: [token2Id, BigInt('4000000')] as [Wallet.Cardano.AssetId, bigint], + info: { ...mockAsset, assetId: token2Id } +}; + +const tokenMap = new Map([token1.balance, token2.balance]); + +describe('Testing transformTokenMap function', () => { + test('given that receive a list of 2 tokens with metadata, should return an array with an array with length 2', () => { + const result = transformTokenMap( + tokenMap, + new Map([ + [token2Id, token2.info], + [token1Id, token1.info] + ]), + mockPrices, + defaultCurrency + ); + expect(result.length).toEqual(2); + }); + + test('given that receive a list of 2 tokens, one with metadata and the other without, should omit the token without metadata', () => { + const result = transformTokenMap(tokenMap, new Map([[token1Id, token1.info]]), mockPrices, defaultCurrency); + expect(result.length).toEqual(1); + }); +}); diff --git a/apps/browser-extension-wallet/src/api/__tests__/wallet-balance-transformer.test.ts b/apps/browser-extension-wallet/src/api/__tests__/wallet-balance-transformer.test.ts new file mode 100644 index 0000000000..6593d40a5a --- /dev/null +++ b/apps/browser-extension-wallet/src/api/__tests__/wallet-balance-transformer.test.ts @@ -0,0 +1,17 @@ +/* eslint-disable no-magic-numbers */ +import '@testing-library/jest-dom'; +import { walletBalanceTransformer } from '../transformers'; + +describe('Testing walletBalanceTransformer function', () => { + test('given a wallet balance in lovelace should return the balance in ada and a - for fiat', () => { + const result = walletBalanceTransformer('10000000'); + expect(result.coinBalance).toBe('10.00'); + expect(result.fiatBalance).toBe('-'); + }); + + test('given a wallet balance in lovelace and a fiat price should return the balance in ada and in fiat', () => { + const result = walletBalanceTransformer('10000000', 2); + expect(result.coinBalance).toBe('10.00'); + expect(result.fiatBalance).toBe('20.00'); + }); +}); diff --git a/apps/browser-extension-wallet/src/api/mock.ts b/apps/browser-extension-wallet/src/api/mock.ts new file mode 100644 index 0000000000..0735415b44 --- /dev/null +++ b/apps/browser-extension-wallet/src/api/mock.ts @@ -0,0 +1,150 @@ +/* eslint-disable no-magic-numbers */ +import { Cardano } from '@cardano-sdk/core'; +import { Wallet } from '@lace/cardano'; + +type Details = + | 'metrics' + | 'relays' + | 'owners' + | 'margin' + | 'cost' + | 'transactions' + | 'vrf' + | 'rewardAccount' + | 'epochRewards'; +type PoolDetails = Pick; + +const pools: Omit[] = [ + { + id: Wallet.Cardano.PoolId('pool1syqhydhdzcuqhwtt6q4m63f9g8e7262wzsvk7e0r0njsyjyd0yn'), + hexId: Wallet.Cardano.PoolIdHex('a76e3a1104a9d816a67d5826a155c9e2979a839d0d944346d47e33ab'), + pledge: BigInt('2000000000'), + status: Wallet.Cardano.StakePoolStatus.Active, + metadata: { + name: 'StakedTestPool', + ticker: 'STTST', + description: 'This is the STTST description', + homepage: 'http://www.sttst.com', + ext: { + serial: 1, + pool: { + id: Wallet.Cardano.PoolIdHex('a76e3a1104a9d816a67d5826a155c9e2979a839d0d944346d47e33ab') + } + } + } + }, + { + id: Wallet.Cardano.PoolId('pool126zlx7728y7xs08s8epg9qp393kyafy9rzr89g4qkvv4cv93zem'), + hexId: Wallet.Cardano.PoolIdHex('b76e3a1104a9d816a67d5826a155c9e2979a839d0d944346d47e33ab'), + pledge: BigInt('3000000000'), + status: Wallet.Cardano.StakePoolStatus.Retired, + metadata: { + name: 'vision', + ticker: 'visn', + description: 'This is the visn description', + homepage: 'http://www.visn.com', + ext: { + serial: 1, + pool: { + id: Wallet.Cardano.PoolIdHex('b76e3a1104a9d816a67d5826a155c9e2979a839d0d944346d47e33ab') + } + } + } + }, + { + id: Wallet.Cardano.PoolId('pool156gxlrk0e3phxadasa33yzk9e94wg7tv3au02jge8eanv9zc4ym'), + hexId: Wallet.Cardano.PoolIdHex('c76e3a1104a9d816a67d5826a155c9e2979a839d0d944346d47e33ab'), + pledge: BigInt('5000000000'), + status: Wallet.Cardano.StakePoolStatus.Active, + metadata: { + name: 'THE AMSTERDAM NODE', + ticker: 'AMS', + description: 'This is the AMS description', + homepage: 'http://www.ams.com', + ext: { + serial: 1, + pool: { + id: Wallet.Cardano.PoolIdHex('c76e3a1104a9d816a67d5826a155c9e2979a839d0d944346d47e33ab') + } + } + } + }, + { + id: Wallet.Cardano.PoolId('pool1tmn4jxlnp64y7hwwwz62vahtqt2maqqj6xy0qnlrhmlmq3u8q0e'), + hexId: Wallet.Cardano.PoolIdHex('d76e3a1104a9d816a67d5826a155c9e2979a839d0d944346d47e33ab'), + pledge: BigInt('1000000000'), + status: Wallet.Cardano.StakePoolStatus.Retired + }, + { + id: Wallet.Cardano.PoolId('pool1jcwn98a6rqr7a7yakanm5sz6asx9gfjsr343mus0tsye23wmg70'), + hexId: Wallet.Cardano.PoolIdHex('e76e3a1104a9d816a67d5826a155c9e2979a839d0d944346d47e33ab'), + pledge: BigInt('7000000000'), + status: Wallet.Cardano.StakePoolStatus.Active, + metadata: { + name: 'TestPool', + ticker: 'TEST', + description: 'This is the TEST description', + homepage: 'http://www.test.com', + ext: { + serial: 1, + pool: { + id: Wallet.Cardano.PoolIdHex('e76e3a1104a9d816a67d5826a155c9e2979a839d0d944346d47e33ab') + } + } + } + }, + { + id: Wallet.Cardano.PoolId('pool1euf2nh92ehqfw7rpd4s9qgq34z8dg4pvfqhjmhggmzk95gcd402'), + hexId: Wallet.Cardano.PoolIdHex('cf12a9dcaacdc09778616d60502011a88ed4542c482f2ddd08d8ac5a'), + pledge: BigInt('500000000'), + status: Wallet.Cardano.StakePoolStatus.Retiring, + metadata: { + name: 'Keiths PiTest', + description: 'Keiths Pi test pool', + ticker: 'KPIT', + homepage: '' + } + }, + { + id: Wallet.Cardano.PoolId('pool1fghrkl620rl3g54ezv56weeuwlyce2tdannm2hphs62syf3vyyh'), + hexId: Wallet.Cardano.PoolIdHex('4a2e3b7f4a78ff1452b91329a7673c77c98ca96dece7b55c37869502'), + pledge: BigInt('1500000000'), + status: Wallet.Cardano.StakePoolStatus.Retiring, + metadata: { + name: 'VEGASPool', + description: 'VEGAS TestNet(2) ADA Pool', + ticker: 'VEGA2', + homepage: 'https://www.ada.vegas' + } + } +]; + +const getDetailsForAll = (): PoolDetails => ({ + cost: BigInt('6040000'), + margin: { + numerator: 1, + denominator: 50 + }, + owners: [ + Wallet.Cardano.RewardAccount('stake_test1uqrw9tjymlm8wrwq7jk68n6v7fs9qz8z0tkdkve26dylmfc2ux2hj'), + Wallet.Cardano.RewardAccount('stake_test1uq7g7kqeucnqfweqzgxk3dw34e8zg4swnc7nagysug2mm4cm77jrx') + ], + metrics: { + blocksCreated: 20, + delegators: 20, + livePledge: BigInt('2000000000'), + saturation: Cardano.Percent(0.95), + size: undefined, + stake: undefined + }, + relays: undefined, + rewardAccount: Wallet.Cardano.RewardAccount('stake_test1uqrw9tjymlm8wrwq7jk68n6v7fs9qz8z0tkdkve26dylmfc2ux2hj'), + transactions: undefined, + vrf: undefined, + epochRewards: [] +}); + +export const mockedStakePools: Wallet.Cardano.StakePool[] = pools.map((pool) => ({ + ...pool, + ...getDetailsForAll() +})); diff --git a/apps/browser-extension-wallet/src/api/transformers.ts b/apps/browser-extension-wallet/src/api/transformers.ts new file mode 100644 index 0000000000..16800fb450 --- /dev/null +++ b/apps/browser-extension-wallet/src/api/transformers.ts @@ -0,0 +1,139 @@ +import { + WalletBalance, + NetworkInformation, + CoinOverview, + CardanoStakePool, + CardanoTxOut, + TransactionDetail, + CurrencyInfo +} from '../types'; +import { Wallet } from '@lace/cardano'; +import { addEllipsis } from '@lace/common'; +import { formatNumber } from '../utils/format-number'; +import { TxOutputInput, CoinItemProps } from '@lace/core'; +import { formatDate, formatTime } from '../utils/format-date'; +import { TokenInfo } from '@src/utils/get-assets-information'; +import { getTokenAmountInFiat, parseFiat } from '@src/utils/assets-transformers'; +import { PriceResult } from '@hooks'; + +export const walletBalanceTransformer = (lovelaceBalance: string, fiat?: number): WalletBalance => { + const adaValue = Wallet.util.lovelacesToAdaString(lovelaceBalance); + + return { + coinBalance: adaValue, + fiatBalance: fiat ? `${Wallet.util.convertAdaToFiat({ ada: adaValue, fiat })}` : '-' + }; +}; + +const coercionMult = 1000; +const coercionDiv = 10; + +export const networkInfoTransformer = ( + { + currentEpoch, + stake, + lovelaceSupply + }: { + currentEpoch: Wallet.EpochInfo; + lovelaceSupply: Wallet.SupplySummary; + stake: Wallet.StakeSummary; + }, + poolStats: Wallet.StakePoolStats +): NetworkInformation => ({ + totalStaked: formatNumber(Wallet.util.lovelacesToAdaString(stake.active.toString())), + totalStakedPercentage: + Number.parseInt(((stake.active * BigInt(coercionMult)) / lovelaceSupply.circulating).toString()) / coercionDiv, + nextEpochIn: currentEpoch.lastSlot.date, + currentEpochIn: currentEpoch.firstSlot.date, + currentEpoch: currentEpoch.epochNo.toString(), + stakePoolsAmount: poolStats.qty.active.toString() +}); + +const tokenSymbolPrefixLength = 8; +const tokenSymbolSuffixLength = 6; + +export const tokenTransformer = ( + assetInfo: Wallet.Asset.AssetInfo, + assetBalance: [Wallet.Cardano.AssetId, bigint], + prices: PriceResult, + fiatCurrency: CurrencyInfo +): CoinOverview => { + const { nftMetadata, tokenMetadata, fingerprint } = assetInfo; + const { name } = { ...tokenMetadata, ...nftMetadata }; + const [assetId, bigintBalance] = assetBalance; + const amount = Wallet.util.calculateAssetBalance(bigintBalance, assetInfo); + const tokenPriceInAda = prices?.tokens?.get(assetId)?.priceInAda; + const fiatBalance = + tokenMetadata !== undefined && + tokenPriceInAda && + prices?.cardano.price && + `${parseFiat(Number(getTokenAmountInFiat(amount, tokenPriceInAda, prices?.cardano.price)))} ${fiatCurrency?.code}`; + + return { + id: assetId.toString(), + amount, + name: name ?? fingerprint.toString(), + symbol: + name ?? + tokenMetadata?.ticker ?? + addEllipsis(fingerprint.toString(), tokenSymbolPrefixLength, tokenSymbolSuffixLength), + logo: tokenMetadata?.icon ?? '', + fiatBalance + }; +}; + +export const transformTokenMap = ( + tokenMap: Wallet.Cardano.TokenMap, + assetsInfo: Map, + coinPrices: PriceResult, + fiatCurrency: CurrencyInfo +): Pick[] => { + if (!tokenMap) return []; + const transformed: CoinOverview[] = []; + for (const [id, amount] of tokenMap) { + const token = assetsInfo.get(id); + // Do not display token if we don't have the info yet + if (token) { + transformed.push(tokenTransformer(token, [id, amount], coinPrices, fiatCurrency)); + } + } + return transformed; +}; + +/** + * Returns slot leader ticker, name or id + */ +const slotLeaderTransformer = (slotLeader: CardanoStakePool): string => + slotLeader?.metadata?.ticker ?? slotLeader?.metadata?.name ?? slotLeader.id.toString(); + +const isStakePool = (props: CardanoStakePool | Wallet.Cardano.SlotLeader): props is CardanoStakePool => + props && (props as CardanoStakePool).id !== undefined; + +/** + * format block information + */ +export const blockTransformer = (block: Wallet.BlockInfo): TransactionDetail['blocks'] => ({ + blockId: block.header.hash.toString(), + epoch: block.epoch.toString(), + block: block.header.blockNo.toString(), + slot: block.header.slot.toString(), + confirmations: block.confirmations.toString(), + size: block.size.toString(), + transactions: block.txCount.toString(), + date: formatDate(block.date, 'MM/DD/YYYY'), + time: `${formatTime(block.date, 'h:mm:ss A')} UTC`, + nextBlock: block.nextBlock ? String(block.header.blockNo.valueOf() + 1) : undefined, + prevBlock: block.previousBlock ? String(block.header.blockNo.valueOf() - 1) : undefined, + createdBy: isStakePool(block.slotLeader) ? slotLeaderTransformer(block.slotLeader) : block.slotLeader?.toString() +}); + +export const inputOutputTransformer = ( + txInOut: Wallet.TxInput | CardanoTxOut, + assets: TokenInfo, + coinPrices: PriceResult, + fiatCurrency: CurrencyInfo +): TxOutputInput => ({ + amount: txInOut.value ? Wallet.util.lovelacesToAdaString(txInOut.value?.coins.toString()) : '0', + assetList: transformTokenMap(txInOut.value?.assets, assets, coinPrices, fiatCurrency) as TxOutputInput['assetList'], + addr: txInOut.address?.toString() ?? '-' +}); diff --git a/apps/browser-extension-wallet/src/assets/branding/icon128.png b/apps/browser-extension-wallet/src/assets/branding/icon128.png new file mode 100644 index 0000000000..364316cb9b Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/branding/icon128.png differ diff --git a/apps/browser-extension-wallet/src/assets/branding/icon16.png b/apps/browser-extension-wallet/src/assets/branding/icon16.png new file mode 100644 index 0000000000..e58d7d84da Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/branding/icon16.png differ diff --git a/apps/browser-extension-wallet/src/assets/branding/icon32.png b/apps/browser-extension-wallet/src/assets/branding/icon32.png new file mode 100644 index 0000000000..588881c468 Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/branding/icon32.png differ diff --git a/apps/browser-extension-wallet/src/assets/branding/icon48.png b/apps/browser-extension-wallet/src/assets/branding/icon48.png new file mode 100644 index 0000000000..e1249ad11d Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/branding/icon48.png differ diff --git a/apps/browser-extension-wallet/src/assets/branding/lace-logo-dark-mode.svg b/apps/browser-extension-wallet/src/assets/branding/lace-logo-dark-mode.svg new file mode 100644 index 0000000000..07f16f0571 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/branding/lace-logo-dark-mode.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/branding/lace-logo-mark.component.svg b/apps/browser-extension-wallet/src/assets/branding/lace-logo-mark.component.svg new file mode 100644 index 0000000000..57ca44a392 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/branding/lace-logo-mark.component.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/branding/lace-logo-mark.svg b/apps/browser-extension-wallet/src/assets/branding/lace-logo-mark.svg new file mode 100644 index 0000000000..57ca44a392 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/branding/lace-logo-mark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/branding/lace-logo.svg b/apps/browser-extension-wallet/src/assets/branding/lace-logo.svg new file mode 100644 index 0000000000..15fc5e1e94 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/branding/lace-logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/branding/lace-portal-horizontal.component.svg b/apps/browser-extension-wallet/src/assets/branding/lace-portal-horizontal.component.svg new file mode 100644 index 0000000000..c632104058 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/branding/lace-portal-horizontal.component.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/branding/lace-portal-popup.component.svg b/apps/browser-extension-wallet/src/assets/branding/lace-portal-popup.component.svg new file mode 100644 index 0000000000..bc61da7fb0 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/branding/lace-portal-popup.component.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/branding/lace-portal-vertical.component.svg b/apps/browser-extension-wallet/src/assets/branding/lace-portal-vertical.component.svg new file mode 100644 index 0000000000..0338496576 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/branding/lace-portal-vertical.component.svg @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/fonts/OpenSauceOne.ttf b/apps/browser-extension-wallet/src/assets/fonts/OpenSauceOne.ttf new file mode 100644 index 0000000000..395a8571b3 Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/fonts/OpenSauceOne.ttf differ diff --git a/apps/browser-extension-wallet/src/assets/fonts/OpenSauceOne.woff b/apps/browser-extension-wallet/src/assets/fonts/OpenSauceOne.woff new file mode 100644 index 0000000000..31738c7da2 Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/fonts/OpenSauceOne.woff differ diff --git a/apps/browser-extension-wallet/src/assets/html/app.html b/apps/browser-extension-wallet/src/assets/html/app.html new file mode 100644 index 0000000000..c6d234179d --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/html/app.html @@ -0,0 +1,14 @@ + + + + + + + + + Lace + + +
+ + diff --git a/apps/browser-extension-wallet/src/assets/html/popup.html b/apps/browser-extension-wallet/src/assets/html/popup.html new file mode 100644 index 0000000000..ea39cd1825 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/html/popup.html @@ -0,0 +1,19 @@ + + + + + + + + + Lace + + + +
+ + diff --git a/apps/browser-extension-wallet/src/assets/html/trezor-usb-permissions.html b/apps/browser-extension-wallet/src/assets/html/trezor-usb-permissions.html new file mode 100644 index 0000000000..105885e12a --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/html/trezor-usb-permissions.html @@ -0,0 +1,32 @@ + + + + + + + TrezorConnect | Trezor + + + + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/Illustration-crypto-1.svg b/apps/browser-extension-wallet/src/assets/icons/Illustration-crypto-1.svg new file mode 100644 index 0000000000..aa36ce8ada --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/Illustration-crypto-1.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/Illustrations.svg b/apps/browser-extension-wallet/src/assets/icons/Illustrations.svg new file mode 100644 index 0000000000..f0f9cc2c50 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/Illustrations.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/browser-extension-wallet/src/assets/icons/LockClosed.svg b/apps/browser-extension-wallet/src/assets/icons/LockClosed.svg new file mode 100644 index 0000000000..f0ec96fe72 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/LockClosed.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/Plus.svg b/apps/browser-extension-wallet/src/assets/icons/Plus.svg new file mode 100644 index 0000000000..ff56f8d1e9 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/Plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/active-assets-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/active-assets-icon.component.svg new file mode 100644 index 0000000000..92d147685b --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/active-assets-icon.component.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/active-database-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/active-database-icon.component.svg new file mode 100644 index 0000000000..9cefbbaf90 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/active-database-icon.component.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/active-nft-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/active-nft-icon.component.svg new file mode 100644 index 0000000000..31bc980281 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/active-nft-icon.component.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/active-transactions-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/active-transactions-icon.component.svg new file mode 100644 index 0000000000..69c0409c25 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/active-transactions-icon.component.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/add-nft-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/add-nft-icon.component.svg new file mode 100644 index 0000000000..841a927b2b --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/add-nft-icon.component.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/add.component.svg b/apps/browser-extension-wallet/src/assets/icons/add.component.svg new file mode 100644 index 0000000000..847d81d111 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/add.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/address-error-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/address-error-icon.component.svg new file mode 100644 index 0000000000..0fd581933e --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/address-error-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/arrow-circle-up.svg b/apps/browser-extension-wallet/src/assets/icons/arrow-circle-up.svg new file mode 100644 index 0000000000..53a1fcf26b --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/arrow-circle-up.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/arrow-down.component.svg b/apps/browser-extension-wallet/src/assets/icons/arrow-down.component.svg new file mode 100644 index 0000000000..c784e77502 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/arrow-down.component.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/arrow.component.svg b/apps/browser-extension-wallet/src/assets/icons/arrow.component.svg new file mode 100644 index 0000000000..74b9ea301f --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/arrow.component.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/assets-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/assets-icon.component.svg new file mode 100644 index 0000000000..fb61770e48 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/assets-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/avatar.component.svg b/apps/browser-extension-wallet/src/assets/icons/avatar.component.svg new file mode 100644 index 0000000000..a28bbe3ba9 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/avatar.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/back-icon.svg b/apps/browser-extension-wallet/src/assets/icons/back-icon.svg new file mode 100644 index 0000000000..81746f7d02 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/back-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/banner-icon.svg b/apps/browser-extension-wallet/src/assets/icons/banner-icon.svg new file mode 100644 index 0000000000..b494a712cf --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/banner-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/book.svg b/apps/browser-extension-wallet/src/assets/icons/book.svg new file mode 100644 index 0000000000..70051e9d82 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/book.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/browser-view/Pattern.svg b/apps/browser-extension-wallet/src/assets/icons/browser-view/Pattern.svg new file mode 100644 index 0000000000..6cec6b9e3c --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/browser-view/Pattern.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/browser-view/cardano-logo.svg b/apps/browser-extension-wallet/src/assets/icons/browser-view/cardano-logo.svg new file mode 100644 index 0000000000..e472b3cfd7 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/browser-view/cardano-logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/browser-view/eye-icon-invisible.component.svg b/apps/browser-extension-wallet/src/assets/icons/browser-view/eye-icon-invisible.component.svg new file mode 100644 index 0000000000..609cb9b5ac --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/browser-view/eye-icon-invisible.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/browser-view/eye-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/browser-view/eye-icon.component.svg new file mode 100644 index 0000000000..7012e377dc --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/browser-view/eye-icon.component.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/browser-view/info-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/browser-view/info-icon.component.svg new file mode 100644 index 0000000000..53978b639f --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/browser-view/info-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/browser-view/info-icon.svg b/apps/browser-extension-wallet/src/assets/icons/browser-view/info-icon.svg new file mode 100644 index 0000000000..1241675fb8 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/browser-view/info-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/browser-view/koi.svg b/apps/browser-extension-wallet/src/assets/icons/browser-view/koi.svg new file mode 100644 index 0000000000..bc0c4af420 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/browser-view/koi.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/browser-view/logo-ikigai.svg b/apps/browser-extension-wallet/src/assets/icons/browser-view/logo-ikigai.svg new file mode 100644 index 0000000000..dfb22c1b47 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/browser-view/logo-ikigai.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/browser-view/mark.svg b/apps/browser-extension-wallet/src/assets/icons/browser-view/mark.svg new file mode 100644 index 0000000000..f8a38c66a2 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/browser-view/mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/browser-view/question-mark.svg b/apps/browser-extension-wallet/src/assets/icons/browser-view/question-mark.svg new file mode 100644 index 0000000000..85eb9824a7 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/browser-view/question-mark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/browser-view/token-logo.svg b/apps/browser-extension-wallet/src/assets/icons/browser-view/token-logo.svg new file mode 100644 index 0000000000..9f1a0bc86b --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/browser-view/token-logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/browser-view/trash.svg b/apps/browser-extension-wallet/src/assets/icons/browser-view/trash.svg new file mode 100644 index 0000000000..65d31c7bfd --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/browser-view/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/bundle-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/bundle-icon.component.svg new file mode 100644 index 0000000000..615478adef --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/bundle-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/camera.svg b/apps/browser-extension-wallet/src/assets/icons/camera.svg new file mode 100644 index 0000000000..ba2d3959f6 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/camera.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/check-success.component.svg b/apps/browser-extension-wallet/src/assets/icons/check-success.component.svg new file mode 100644 index 0000000000..55ec86a6a3 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/check-success.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/chevron-down-small.component.svg b/apps/browser-extension-wallet/src/assets/icons/chevron-down-small.component.svg new file mode 100644 index 0000000000..a214f7be1f --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/chevron-down-small.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/chevron-down.component.svg b/apps/browser-extension-wallet/src/assets/icons/chevron-down.component.svg new file mode 100644 index 0000000000..7fba5ac81f --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/chevron-down.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/clock-icon.svg b/apps/browser-extension-wallet/src/assets/icons/clock-icon.svg new file mode 100644 index 0000000000..cbee59d6fd --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/clock-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/copy.component.svg b/apps/browser-extension-wallet/src/assets/icons/copy.component.svg new file mode 100644 index 0000000000..8292f7181b --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/copy.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/cross.component.svg b/apps/browser-extension-wallet/src/assets/icons/cross.component.svg new file mode 100644 index 0000000000..2156a38323 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/cross.component.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/database-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/database-icon.component.svg new file mode 100644 index 0000000000..24ca8d6de3 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/database-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/delete-folder-plain-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/delete-folder-plain-icon.component.svg new file mode 100644 index 0000000000..c46b823d66 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/delete-folder-plain-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/delete-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/delete-icon.component.svg new file mode 100644 index 0000000000..515a41acc0 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/delete-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/edit.component.svg b/apps/browser-extension-wallet/src/assets/icons/edit.component.svg new file mode 100644 index 0000000000..0f97da3a83 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/edit.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/empty.svg b/apps/browser-extension-wallet/src/assets/icons/empty.svg new file mode 100644 index 0000000000..9d3a829aa2 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/empty.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/exclamation-circle-small.component.svg b/apps/browser-extension-wallet/src/assets/icons/exclamation-circle-small.component.svg new file mode 100644 index 0000000000..404cef844d --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/exclamation-circle-small.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/exclamation-circle-small.svg b/apps/browser-extension-wallet/src/assets/icons/exclamation-circle-small.svg new file mode 100644 index 0000000000..7a77480ac4 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/exclamation-circle-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/exclamation-circle.svg b/apps/browser-extension-wallet/src/assets/icons/exclamation-circle.svg new file mode 100644 index 0000000000..85439670f7 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/exclamation-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/expand-gradient.component.svg b/apps/browser-extension-wallet/src/assets/icons/expand-gradient.component.svg new file mode 100644 index 0000000000..9d46bc35b3 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/expand-gradient.component.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/expand.component.svg b/apps/browser-extension-wallet/src/assets/icons/expand.component.svg new file mode 100644 index 0000000000..de780c9072 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/expand.component.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/extension.component.svg b/apps/browser-extension-wallet/src/assets/icons/extension.component.svg new file mode 100644 index 0000000000..31db4f15e0 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/extension.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/eye.svg b/apps/browser-extension-wallet/src/assets/icons/eye.svg new file mode 100644 index 0000000000..5eb8d41e5e --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/eye.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/facebook-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/facebook-icon.component.svg new file mode 100644 index 0000000000..1ea54cdfb6 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/facebook-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/feed-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/feed-icon.component.svg new file mode 100644 index 0000000000..9dbbabdca5 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/feed-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/folder.component.svg b/apps/browser-extension-wallet/src/assets/icons/folder.component.svg new file mode 100644 index 0000000000..e7a8c866f6 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/folder.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/github-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/github-icon.component.svg new file mode 100644 index 0000000000..f7e289a035 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/github-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/hover-assets-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/hover-assets-icon.component.svg new file mode 100644 index 0000000000..3a3a427aae --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/hover-assets-icon.component.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/hover-database-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/hover-database-icon.component.svg new file mode 100644 index 0000000000..b552cc90be --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/hover-database-icon.component.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/hover-nft-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/hover-nft-icon.component.svg new file mode 100644 index 0000000000..6e1ee71ec6 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/hover-nft-icon.component.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/hover-transactions-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/hover-transactions-icon.component.svg new file mode 100644 index 0000000000..8caa93ac66 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/hover-transactions-icon.component.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/illustration-passphrase.svg b/apps/browser-extension-wallet/src/assets/icons/illustration-passphrase.svg new file mode 100644 index 0000000000..308df9233a --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/illustration-passphrase.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/info.component.svg b/apps/browser-extension-wallet/src/assets/icons/info.component.svg new file mode 100644 index 0000000000..015bea9544 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/info.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/light.svg b/apps/browser-extension-wallet/src/assets/icons/light.svg new file mode 100644 index 0000000000..5cc3adcb33 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/light.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/medium-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/medium-icon.component.svg new file mode 100644 index 0000000000..3c94b79a17 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/medium-icon.component.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/moon.component.svg b/apps/browser-extension-wallet/src/assets/icons/moon.component.svg new file mode 100644 index 0000000000..3bbb8c126d --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/moon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/more-outlined.component.svg b/apps/browser-extension-wallet/src/assets/icons/more-outlined.component.svg new file mode 100644 index 0000000000..0e4bded20f --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/more-outlined.component.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/new-folder-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/new-folder-icon.component.svg new file mode 100644 index 0000000000..4edb893454 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/new-folder-icon.component.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/new-folder-plain-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/new-folder-plain-icon.component.svg new file mode 100644 index 0000000000..7064899b9f --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/new-folder-plain-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/nft-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/nft-icon.component.svg new file mode 100644 index 0000000000..ceea017397 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/nft-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/plus-icon.svg b/apps/browser-extension-wallet/src/assets/icons/plus-icon.svg new file mode 100644 index 0000000000..f2efff6e12 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/plus-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/purple-question-mark.svg b/apps/browser-extension-wallet/src/assets/icons/purple-question-mark.svg new file mode 100644 index 0000000000..0cc93e3981 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/purple-question-mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/question-mark.svg b/apps/browser-extension-wallet/src/assets/icons/question-mark.svg new file mode 100644 index 0000000000..15551b3a49 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/question-mark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/remove-folder.component.svg b/apps/browser-extension-wallet/src/assets/icons/remove-folder.component.svg new file mode 100644 index 0000000000..252fb9f736 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/remove-folder.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/search.component.svg b/apps/browser-extension-wallet/src/assets/icons/search.component.svg new file mode 100644 index 0000000000..5421bc5071 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/search.component.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/shield-exclamation.svg b/apps/browser-extension-wallet/src/assets/icons/shield-exclamation.svg new file mode 100644 index 0000000000..2287232ad8 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/shield-exclamation.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/site-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/site-icon.component.svg new file mode 100644 index 0000000000..713a32eab3 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/site-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/success-staking.svg b/apps/browser-extension-wallet/src/assets/icons/success-staking.svg new file mode 100644 index 0000000000..02d98ebc01 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/success-staking.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/sun.component.svg b/apps/browser-extension-wallet/src/assets/icons/sun.component.svg new file mode 100644 index 0000000000..439722d393 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/sun.component.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/support-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/support-icon.component.svg new file mode 100644 index 0000000000..810435b949 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/support-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/switch.component.svg b/apps/browser-extension-wallet/src/assets/icons/switch.component.svg new file mode 100644 index 0000000000..9b2b3c2dbf --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/switch.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/telegram-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/telegram-icon.component.svg new file mode 100644 index 0000000000..ce2e14e9bb --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/telegram-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/ticket-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/ticket-icon.component.svg new file mode 100644 index 0000000000..77cccaf643 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/ticket-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/transactions-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/transactions-icon.component.svg new file mode 100644 index 0000000000..7c62d8b166 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/transactions-icon.component.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/trash-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/trash-icon.component.svg new file mode 100644 index 0000000000..0d2a507ea1 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/trash-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/twitter-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/twitter-icon.component.svg new file mode 100644 index 0000000000..4818791db3 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/twitter-icon.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/user.component.svg b/apps/browser-extension-wallet/src/assets/icons/user.component.svg new file mode 100644 index 0000000000..526397edfe --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/user.component.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/video.svg b/apps/browser-extension-wallet/src/assets/icons/video.svg new file mode 100644 index 0000000000..7fe25585a0 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/video.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/icons/youtube-icon.component.svg b/apps/browser-extension-wallet/src/assets/icons/youtube-icon.component.svg new file mode 100644 index 0000000000..28d673bfda --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/youtube-icon.component.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/images/Exclamation.png b/apps/browser-extension-wallet/src/assets/images/Exclamation.png new file mode 100644 index 0000000000..f6a785dbe4 Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/images/Exclamation.png differ diff --git a/apps/browser-extension-wallet/src/assets/images/cardano-blue-bg.png b/apps/browser-extension-wallet/src/assets/images/cardano-blue-bg.png new file mode 100644 index 0000000000..0b8904b22d Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/images/cardano-blue-bg.png differ diff --git a/apps/browser-extension-wallet/src/assets/images/cardano.webp b/apps/browser-extension-wallet/src/assets/images/cardano.webp new file mode 100644 index 0000000000..2e9657e173 Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/images/cardano.webp differ diff --git a/apps/browser-extension-wallet/src/assets/images/educational-illustration.png b/apps/browser-extension-wallet/src/assets/images/educational-illustration.png new file mode 100644 index 0000000000..ad14d35534 Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/images/educational-illustration.png differ diff --git a/apps/browser-extension-wallet/src/assets/images/portal.png b/apps/browser-extension-wallet/src/assets/images/portal.png new file mode 100644 index 0000000000..b58833484d Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/images/portal.png differ diff --git a/apps/browser-extension-wallet/src/assets/images/portal2.png b/apps/browser-extension-wallet/src/assets/images/portal2.png new file mode 100644 index 0000000000..28dc08a8ba Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/images/portal2.png differ diff --git a/apps/browser-extension-wallet/src/assets/images/start_staking_bg.png b/apps/browser-extension-wallet/src/assets/images/start_staking_bg.png new file mode 100644 index 0000000000..1da8158043 Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/images/start_staking_bg.png differ diff --git a/apps/browser-extension-wallet/src/assets/images/start_staking_bg_popup.png b/apps/browser-extension-wallet/src/assets/images/start_staking_bg_popup.png new file mode 100644 index 0000000000..08c99109fd Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/images/start_staking_bg_popup.png differ diff --git a/apps/browser-extension-wallet/src/components/Announcement/Announcement.module.scss b/apps/browser-extension-wallet/src/components/Announcement/Announcement.module.scss new file mode 100644 index 0000000000..bcc9e44355 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Announcement/Announcement.module.scss @@ -0,0 +1,75 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../packages/common/src/ui/styles/abstracts/_typography'; +@import '../../../../../packages/common/src/ui/styles/abstracts/mixins'; +@import '../../styles/rules/modal.scss'; + +.continueInBrowser { + @extend %modal-globals; +} + +.modal { + &:global(.ant-modal) { + max-width: calc(100vw - #{size_unit(6)}) !important; + max-height: calc(100vh - 80px) !important; + } + :global(.ant-modal-content) { + background: var(--color-white, var(--dark-mode-light-black)) !important; + border-radius: size_unit(2) !important; + box-shadow: var(--shadows-card-pop-up) !important; + max-height: calc(100vh - 80px) !important; + } + :global(.ant-modal-body) { + display: flex; + flex-direction: column; + padding: size_unit(5) !important; + max-height: calc(100vh - 80px) !important; + } +} + +.container { + display: flex; + flex: 1; + overflow: hidden; + margin-left: -#{size_unit(5)}; + margin-right: -#{size_unit(3)}; + .content { + @include scroll-bar-style; + display: flex; + flex-direction: column; + overflow: auto; + padding-right: size_unit(3); + padding-left: size_unit(5); + } +} + +.badge { + @include text-bodyXS-medium; + align-content: center; + align-self: flex-start; + border-radius: size_unit(2); + flex-wrap: wrap; + background: var(--primary-gradient); + color: var(--bg-color-body); + display: flex; + height: 20px; + min-height: 20px; + margin-bottom: size_unit(1); + padding: 0 size_unit(1); +} + +.title { + @include text-subHeading-bold; + color: var(--text-color-primary) !important; + margin-bottom: 0 !important; +} + +.description { + @include text-body-medium; + display: block; + margin-top: size_unit(2) !important; + color: var(--text-color-primary) !important; +} + +.button { + margin-top: size_unit(3) !important; +} diff --git a/apps/browser-extension-wallet/src/components/Announcement/Announcement.tsx b/apps/browser-extension-wallet/src/components/Announcement/Announcement.tsx new file mode 100644 index 0000000000..e1a456a1d6 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Announcement/Announcement.tsx @@ -0,0 +1,63 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, Typography } from 'antd'; +import { Button } from '@lace/common'; +import styles from './Announcement.module.scss'; +import { fetchNotes } from './ReleaseNotes'; +import { ExtensionUpdateData } from '@lib/scripts/types'; + +const { Title, Text } = Typography; + +interface AnnouncementProps { + visible: boolean; + onConfirm: () => void; + version: string; + reason: ExtensionUpdateData['reason']; +} + +export const Announcement = ({ visible, onConfirm, version, reason }: AnnouncementProps): React.ReactElement => { + // eslint-disable-next-line unicorn/no-null + const [releaseNotes, setReleaseNotes] = useState(''); + const { t } = useTranslation(); + + const loadReleaseNotes = useCallback(async () => { + let notes = ''; + if (version) { + try { + notes = await fetchNotes(version); + } catch (error) { + console.log(error); + } + } + setReleaseNotes(notes); + }, [version]); + + useEffect(() => { + loadReleaseNotes(); + }, [version, loadReleaseNotes]); + + return ( + +
+
+ {reason !== 'downgrade' &&
{t('announcement.title.badge')}
} + + {`${version} ${t('announcement.title.text')}`} + + {releaseNotes} +
+
+ +
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/Announcement/ReleaseNotes.tsx b/apps/browser-extension-wallet/src/components/Announcement/ReleaseNotes.tsx new file mode 100644 index 0000000000..2df088b6e2 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Announcement/ReleaseNotes.tsx @@ -0,0 +1,7 @@ +const convertVersionFormat = (version: string) => version.replace(/\./g, '_'); + +export const fetchNotes = async (version: string): Promise => { + const notes = await import(/* webpackMode: "eager" */ `../../release-notes/${convertVersionFormat(version)}.tsx`); + + return notes.default; +}; diff --git a/apps/browser-extension-wallet/src/components/AssetSelectionButton/AssetCounter.modules.scss b/apps/browser-extension-wallet/src/components/AssetSelectionButton/AssetCounter.modules.scss new file mode 100644 index 0000000000..868517c5ef --- /dev/null +++ b/apps/browser-extension-wallet/src/components/AssetSelectionButton/AssetCounter.modules.scss @@ -0,0 +1,24 @@ +@import '../../../../../packages/common/src/ui/styles/abstracts/typography.scss'; + +.container { + color: var(--text-color-primary, #ffffff); + background-color: var(--color-white, var(--dark-mode-bg-black, #ffffff)); + background: linear-gradient( + var(--color-white, var(--dark-mode-bg-black, #ffffff)), + var(--color-white, var(--dark-mode-bg-black, #ffffff)) + ) + padding-box, + var(--lace-gradient) border-box; + border: 1.5px solid transparent; + border-radius: 100%; + width: 40px; + height: 40px; + display: flex; + justify-content: center; + align-items: center; + + p { + @include text-body-bold; + margin: 0; + } +} diff --git a/apps/browser-extension-wallet/src/components/AssetSelectionButton/AssetCounter.tsx b/apps/browser-extension-wallet/src/components/AssetSelectionButton/AssetCounter.tsx new file mode 100644 index 0000000000..1b486bb7b9 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/AssetSelectionButton/AssetCounter.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import styles from './AssetCounter.modules.scss'; + +interface AssetsCounterProps { + count: number; +} + +export const AssetsCounter = ({ count }: AssetsCounterProps): React.ReactElement => ( +
+

{count}

+
+); diff --git a/apps/browser-extension-wallet/src/components/AssetSelectionButton/SelectTokensButton.module.scss b/apps/browser-extension-wallet/src/components/AssetSelectionButton/SelectTokensButton.module.scss new file mode 100644 index 0000000000..8a91692eb6 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/AssetSelectionButton/SelectTokensButton.module.scss @@ -0,0 +1,41 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../packages/common/src/ui/styles/abstracts/typography.scss'; + +.container { + display: flex; + gap: 12px; + align-items: center; +} + +.selectMultipleBtn { + padding: 0 !important; + max-height: size_unit(5) !important; + min-height: size_unit(5) !important; + height: size_unit(5) !important; + color: var(--text-color-primary) !important; + background-color: var(--color-white, transparent) !important; + border: 1.5px solid var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey, #333333)) !important; + border-radius: size_unit(1.5) !important; + + @include text-form-label(16px !important, 600 !important, 24px !important); + + &:hover { + background-color: var(--light-mode-light-grey, var(--dark-mode-light-black, #282828)) !important; + border: 1.5px solid var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey, #333333)) !important; + } + + &:active { + background-color: var(--light-mode-light-grey-plus-56, var(--dark-mode-light-black, #282828)) !important; + border: 1.5px solid var(--light-mode-light-grey-plus, transparent) !important; + } + + &:disabled { + opacity: 0.24; + background-color: var(--color-white, transparent); + border: 1.5px solid var(--light-mode-mid-grey, var(--dark-mode-mid-grey, #333333)) !important; + } + + &:focus { + border: 1.5px solid var(--primary-default, #7f5af0); + } +} diff --git a/apps/browser-extension-wallet/src/components/AssetSelectionButton/SelectTokensButton.tsx b/apps/browser-extension-wallet/src/components/AssetSelectionButton/SelectTokensButton.tsx new file mode 100644 index 0000000000..910c201769 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/AssetSelectionButton/SelectTokensButton.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Button } from '@lace/common'; +import { AssetsCounter } from './AssetCounter'; +import styles from './SelectTokensButton.module.scss'; + +interface SelectTokenButtonProps { + label: string; + onClick?: React.MouseEventHandler; + count?: number; + btnStyle?: React.CSSProperties; +} + +export const SelectTokenButton = ({ + label, + onClick, + count = 0, + btnStyle +}: SelectTokenButtonProps): React.ReactElement => ( +
+ {count > 0 && } + +
+); diff --git a/apps/browser-extension-wallet/src/components/BackButton/BackButton.module.scss b/apps/browser-extension-wallet/src/components/BackButton/BackButton.module.scss new file mode 100644 index 0000000000..d624c9c4da --- /dev/null +++ b/apps/browser-extension-wallet/src/components/BackButton/BackButton.module.scss @@ -0,0 +1,12 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; + +.BackButtonContainer { + position: absolute; + top: size_unit(3); + left: size_unit(3); + z-index: 9; +} +.icon { + margin-right: size_unit(1); + width: size_unit(2); +} diff --git a/apps/browser-extension-wallet/src/components/BackButton/BackButton.tsx b/apps/browser-extension-wallet/src/components/BackButton/BackButton.tsx new file mode 100644 index 0000000000..0d254dc52a --- /dev/null +++ b/apps/browser-extension-wallet/src/components/BackButton/BackButton.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Button } from '@lace/common'; +import styles from './BackButton.module.scss'; +import arrowBack from '../../assets/icons/back-icon.svg'; + +export interface BackButtonProps { + label: string; + onBackClick: (event?: React.MouseEvent) => void; + dataTestid?: string; +} + +export const BackButton = ({ + label = 'Back', + onBackClick, + dataTestid = 'back-btn' +}: BackButtonProps): React.ReactElement => ( +
+ +
+); diff --git a/apps/browser-extension-wallet/src/components/BackButton/index.ts b/apps/browser-extension-wallet/src/components/BackButton/index.ts new file mode 100644 index 0000000000..b68e24e8a3 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/BackButton/index.ts @@ -0,0 +1 @@ +export * from './BackButton'; diff --git a/apps/browser-extension-wallet/src/components/Banner/Banner.module.scss b/apps/browser-extension-wallet/src/components/Banner/Banner.module.scss new file mode 100644 index 0000000000..aebf093443 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Banner/Banner.module.scss @@ -0,0 +1,51 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; + +.bannerContainer { + width: 100%; + background-color: var(--dark-mode-dark-grey-plus, var(--color-magnolia, #fcf5e3)); + border-radius: 16px; + padding: size_unit(2) 27px; + display: flex; + align-items: center; + justify-content: center; + margin-top: size_unit(1); + + .iconContainer { + align-self: center; + margin-right: 27px; + @media (max-width: $breakpoint-popup) { + margin-top: -#{size_unit(3)}; + } + &.withDescription { + margin-top: size_unit(1); + } + } + + .descriptionContainer { + width: 100%; + display: flex; + flex-direction: column; + + span.message { + color: var(--text-color-primary); + font-size: var(--body); + font-weight: 600; + line-height: size_unit(3); + text-align: left; + white-space: pre-line; + } + + span.description { + color: var(--text-color-primary); + font-size: var(--body); + font-weight: 400; + line-height: size_unit(3); + letter-spacing: -0.015em; + text-align: left; + + @media (max-width: $breakpoint-popup) { + font-size: var(--bodySmall); + } + } + } +} diff --git a/apps/browser-extension-wallet/src/components/Banner/Banner.tsx b/apps/browser-extension-wallet/src/components/Banner/Banner.tsx new file mode 100644 index 0000000000..e46dd5657d --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Banner/Banner.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Typography } from 'antd'; +import cn from 'classnames'; +import Icon from '../../assets/icons/banner-icon.svg'; +import styles from './Banner.module.scss'; + +const { Text } = Typography; + +const shouldBeDisplayedAsText = (message: React.ReactNode) => + typeof message === 'string' || typeof message === 'number'; + +export interface BannerProps { + withIcon?: boolean; + customIcon?: string; + message: string; + className?: string; + description?: React.ReactNode; +} + +export const Banner = ({ message, description, customIcon, withIcon, className }: BannerProps): React.ReactElement => { + const descriptionElement = shouldBeDisplayedAsText(description) ? ( + {description} + ) : ( + description + ); + return ( +
+ {withIcon && ( +
+ icon +
+ )} +
+ {message} + {description &&
{descriptionElement}
} +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/Banner/index.ts b/apps/browser-extension-wallet/src/components/Banner/index.ts new file mode 100644 index 0000000000..bc95f09d62 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Banner/index.ts @@ -0,0 +1 @@ +export * from './Banner'; diff --git a/apps/browser-extension-wallet/src/components/ContinueInBrowserDialog/ContinueInBrowserDialog.module.scss b/apps/browser-extension-wallet/src/components/ContinueInBrowserDialog/ContinueInBrowserDialog.module.scss new file mode 100644 index 0000000000..1875ddbde4 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/ContinueInBrowserDialog/ContinueInBrowserDialog.module.scss @@ -0,0 +1,27 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../styles/rules/modal.scss'; + +.continueInBrowser { + @extend %modal-globals; +} + +.title { + text-align: center !important; + color: var(--text-color-primary) !important; +} + +.description { + margin-top: size_unit(2) !important; + text-align: center; + color: var(--text-color-secondary) !important; + font-size: size_unit(2) !important; + font-weight: 500 !important; +} + +.buttons { + display: flex; + gap: size_unit(2); + margin-top: size_unit(3) !important; + flex-direction: column; + width: size_unit(27); +} diff --git a/apps/browser-extension-wallet/src/components/ContinueInBrowserDialog/ContinueInBrowserDialog.tsx b/apps/browser-extension-wallet/src/components/ContinueInBrowserDialog/ContinueInBrowserDialog.tsx new file mode 100644 index 0000000000..ecc1694655 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/ContinueInBrowserDialog/ContinueInBrowserDialog.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import styles from './ContinueInBrowserDialog.module.scss'; +import { Button } from '@lace/common'; +import { Modal, Typography } from 'antd'; +import { HW_POPUPS_WIDTH } from '@src/utils/constants'; + +const { Title, Text } = Typography; + +interface ContinueInBrowserDialogProps { + visible: boolean; + onConfirm: () => void; + onClose: () => void; + title: string; + description: string; + okLabel: string; + cancelLabel: string; +} + +export const ContinueInBrowserDialog = ({ + visible, + onConfirm, + onClose, + title, + description, + okLabel, + cancelLabel +}: ContinueInBrowserDialogProps): React.ReactElement => ( + + + {title} + + {description} +
+ + +
+
+); diff --git a/apps/browser-extension-wallet/src/components/ContinueInBrowserDialog/index.ts b/apps/browser-extension-wallet/src/components/ContinueInBrowserDialog/index.ts new file mode 100644 index 0000000000..e6bf3d4bd7 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/ContinueInBrowserDialog/index.ts @@ -0,0 +1 @@ +export { ContinueInBrowserDialog } from './ContinueInBrowserDialog'; diff --git a/apps/browser-extension-wallet/src/components/Credit/Credit.module.scss b/apps/browser-extension-wallet/src/components/Credit/Credit.module.scss new file mode 100644 index 0000000000..32130c797b --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Credit/Credit.module.scss @@ -0,0 +1,26 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../packages/common/src/ui/styles/abstracts/typography'; + +.credits { + margin-bottom: size_unit(5); + border-top: 1px solid var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey)); + padding-top: size_unit(2); + + @media (max-width: $breakpoint-popup) { + margin: 0 size_unit(3) size_unit(3); + } + + div { + @include text-bodySmall-semi-bold; + color: var(--text-color-secondary); + display: flex; + gap: size_unit(0.5); + justify-content: flex-end; + margin-right: size_unit(0.5); + } + + .link { + color: var(--text-color-blue, #3489f7); + cursor: pointer; + } +} diff --git a/apps/browser-extension-wallet/src/components/Credit/Credit.tsx b/apps/browser-extension-wallet/src/components/Credit/Credit.tsx new file mode 100644 index 0000000000..6f69217783 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Credit/Credit.tsx @@ -0,0 +1,22 @@ +import React, { ReactElement } from 'react'; +import styles from './Credit.module.scss'; +import { useTranslation } from 'react-i18next'; + +interface CreditsProps { + handleOnClick: () => void; +} + +export const Credit = ({ handleOnClick }: CreditsProps): ReactElement => { + const { t } = useTranslation(); + + return ( +
+
+ {t('general.credit.poweredBy')}{' '} +
+ {t('general.credit.coinGecko')} +
+
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/Credit/index.ts b/apps/browser-extension-wallet/src/components/Credit/index.ts new file mode 100644 index 0000000000..6b485ad36f --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Credit/index.ts @@ -0,0 +1 @@ +export * from './Credit'; diff --git a/apps/browser-extension-wallet/src/components/DropdownMenu/DropdownMenu.module.scss b/apps/browser-extension-wallet/src/components/DropdownMenu/DropdownMenu.module.scss new file mode 100644 index 0000000000..7d8322a7b9 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/DropdownMenu/DropdownMenu.module.scss @@ -0,0 +1,47 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; + +.avatarBtn { + min-height: size_unit(6) !important; + min-width: 0 !important; + padding: 0 !important; + border-radius: 100px !important; + border-width: 2px !important; + background-color: var(--bg-color-body, #ffffff) !important; + + &.open { + background: var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey, #333333)) !important; + &:hover { + background: var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey, #333333)) !important; + } + } + + @media (max-width: $breakpoint-popup) { + min-height: size_unit(4.5) !important; + min-width: 0 !important; + } +} + +.content { + align-items: center; + display: flex; + margin: 0 6px; + &.isPopup { + margin: 0 3px; + + .chevron { + font-size: 9px; + margin-left: 6px; + margin-right: 6px; + } + } +} + +.chevron { + color: var(--dark-mode-light-grey, var(--light-mode-dark-grey, #878e9e)) !important; + font-size: 11px; + margin-left: 10px; + margin-right: 7px; + &.open { + transform: rotateX(180deg); + } +} diff --git a/apps/browser-extension-wallet/src/components/DropdownMenu/DropdownMenu.tsx b/apps/browser-extension-wallet/src/components/DropdownMenu/DropdownMenu.tsx new file mode 100644 index 0000000000..3ad8faf9a8 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/DropdownMenu/DropdownMenu.tsx @@ -0,0 +1,46 @@ +import React, { useState } from 'react'; +import cn from 'classnames'; +import { Dropdown } from 'antd'; +import { Button } from '@lace/common'; +import { DropdownMenuOverlay } from '../MainMenu'; + +import ChevronNormal from '../../assets/icons/chevron-down.component.svg'; +import ChevronSmall from '../../assets/icons/chevron-down-small.component.svg'; +import styles from './DropdownMenu.module.scss'; +import { useWalletStore } from '@src/stores'; +import { UserAvatar } from '../MainMenu/DropdownMenuOverlay/components'; + +export interface DropdownMenuProps { + isPopup?: boolean; +} + +export const DropdownMenu = ({ isPopup }: DropdownMenuProps): React.ReactElement => { + const { walletInfo } = useWalletStore(); + const [open, setOpen] = useState(false); + const Chevron = isPopup ? ChevronSmall : ChevronNormal; + + return ( + } + placement="bottomRight" + trigger={['click']} + > + + + ); +}; diff --git a/apps/browser-extension-wallet/src/components/DropdownMenu/index.ts b/apps/browser-extension-wallet/src/components/DropdownMenu/index.ts new file mode 100644 index 0000000000..8aadf2fa5f --- /dev/null +++ b/apps/browser-extension-wallet/src/components/DropdownMenu/index.ts @@ -0,0 +1 @@ +export * from './DropdownMenu'; diff --git a/apps/browser-extension-wallet/src/components/ExpandButton/ExpandButton.module.scss b/apps/browser-extension-wallet/src/components/ExpandButton/ExpandButton.module.scss new file mode 100644 index 0000000000..dfa9d4f4f9 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/ExpandButton/ExpandButton.module.scss @@ -0,0 +1,50 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../packages/common/src/ui/styles/abstracts/typography'; + +.button { + background-color: var(--bg-color-body); + border-radius: size_unit(10); + border: 2px solid var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey, #333333)); + cursor: pointer; + gap: 0px; + text-decoration: none; + height: size_unit(4.5); + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + overflow: hidden; + width: auto; + max-width: size_unit(4.5); + transition: all 0.5s ease, background-color 0s, border 0s; + + &:hover { + max-width: 300px; + gap: size_unit(1); + padding: 0 size_unit(2); + + background: var(--light-mode-light-grey, var(--dark-mode-light-black, #282828)); + + .text { + max-width: 100%; + } + } + + &:active, + &:focus { + background: var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey, #333333)); + } +} + +.text { + color: var(--text-color-secondary); + white-space: nowrap; + max-width: 0%; + overflow: hidden; + transition: all 0.5s; +} + +.icon { + font-size: size_unit(2.5); + color: var(--light-mode-dark-grey, var(--dark-mode-light-grey, #a9a9a9)); +} diff --git a/apps/browser-extension-wallet/src/components/ExpandButton/ExpandButton.tsx b/apps/browser-extension-wallet/src/components/ExpandButton/ExpandButton.tsx new file mode 100644 index 0000000000..1e7da942e1 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/ExpandButton/ExpandButton.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ExpandIcon from '../../assets/icons/expand.component.svg'; + +import styles from './ExpandButton.module.scss'; + +export const ExpandButton = ({ label, onClick }: { label: string; onClick: () => void }): React.ReactElement => ( + + + {label} + +); diff --git a/apps/browser-extension-wallet/src/components/ExpandButton/index.tsx b/apps/browser-extension-wallet/src/components/ExpandButton/index.tsx new file mode 100644 index 0000000000..619122b569 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/ExpandButton/index.tsx @@ -0,0 +1 @@ +export * from './ExpandButton'; diff --git a/apps/browser-extension-wallet/src/components/Layout/ContentLayout.module.scss b/apps/browser-extension-wallet/src/components/Layout/ContentLayout.module.scss new file mode 100644 index 0000000000..75b32ecda1 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Layout/ContentLayout.module.scss @@ -0,0 +1,34 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../packages/common/src/ui/styles/abstracts/mixins'; +@import '../../../../../packages/common/src/ui/styles/abstracts/variables'; + +.wrapper { + padding-right: 0; + &.hasScollBar { + padding-right: size_unit(1); + } +} + +.content { + overflow: auto; + padding-top: size_unit(3); + width: 100%; + background-color: var(--bg-color-body); + @media (max-width: $breakpoint-popup) { + padding: size_unit(2) 1px 0 1px; + height: 100%; + } + .MainContainer { + padding: 0 size_unit(3); + height: auto; + &.hasScollBar { + padding-right: calc(#{size_unit(3)} - #{size_unit(1)} - #{$scroll-bar-default-width}); + } + } + + @include scroll-bar-style; +} + +.spinnerContainer { + padding: size_unit(2); +} diff --git a/apps/browser-extension-wallet/src/components/Layout/ContentLayout.tsx b/apps/browser-extension-wallet/src/components/Layout/ContentLayout.tsx new file mode 100644 index 0000000000..b32d88d24f --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Layout/ContentLayout.tsx @@ -0,0 +1,89 @@ +import React, { useRef, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { Skeleton } from 'antd'; +import cn from 'classnames'; +import { useHasScrollBar } from '@lace/common'; +import { walletRoutePaths } from '@routes/wallet-paths'; +import { SectionTitle, SectionTitleProps } from './SectionTitle'; +import { Credit } from '@components/Credit'; +import { COINGECKO_URL } from '@utils/constants'; +import styles from './ContentLayout.module.scss'; + +export type LayoutProps = { + children: React.ReactNode; + title?: string | React.ReactElement; + titleSideText?: string | React.ReactElement; + isLoading?: boolean; + footer?: React.ReactNode; + id?: string; + mainClassName?: string; + 'data-testid'?: string; + hasCredit?: boolean; +} & Omit; + +export const CONTENT_LAYOUT_ID = 'contentLayout'; + +const openExternalLink = (url: string) => window.open(url, '_blank', 'noopener,noreferrer'); + +export const ContentLayout = ({ + title, + children, + withIcon, + handleIconClick, + isLoading, + id = CONTENT_LAYOUT_ID, + titleSideText, + mainClassName, + hasCredit = false, + ...rest +}: LayoutProps): React.ReactElement => { + const location = useLocation<{ pathname: string }>(); + const mvpStyledRoutes = new Set([walletRoutePaths.addressBook, walletRoutePaths.earn]); + const isMvpStyledRoute = mvpStyledRoutes.has(location?.pathname); + + const scrollabelContainer = useRef(); + const [hasScollBar, setHasScrollBar] = useState(false); + useHasScrollBar(scrollabelContainer, setHasScrollBar); + + const content = ( + <> + {typeof title === 'string' ? ( + + ) : ( + title + )} + {isLoading ? ( +
+ +
+ ) : ( +
+ {children} + {hasCredit && openExternalLink(COINGECKO_URL)} />} +
+ )} + + ); + + return ( +
+
+ {content} +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/Layout/MainLayout.module.scss b/apps/browser-extension-wallet/src/components/Layout/MainLayout.module.scss new file mode 100644 index 0000000000..1d6d8f2d13 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Layout/MainLayout.module.scss @@ -0,0 +1,31 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../packages/common/src/ui/styles/abstracts/mixins'; + +.layoutContainer { + display: flex; + height: 100vh; + flex-direction: column; + background-color: var(--bg-color-body); +} + +.layoutContent { + flex: 1; + min-height: 0; + .contentWrapper { + display: flex; + flex-direction: column; + height: 100%; + .content { + overflow-y: auto; + flex: 1; + display: flex; + min-height: 0; + width: 100%; + > * { + width: 100%; + } + + @include scroll-bar-style; + } + } +} diff --git a/apps/browser-extension-wallet/src/components/Layout/MainLayout.tsx b/apps/browser-extension-wallet/src/components/Layout/MainLayout.tsx new file mode 100644 index 0000000000..e606d8a721 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Layout/MainLayout.tsx @@ -0,0 +1,84 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import debounce from 'lodash/debounce'; +import { useTranslation } from 'react-i18next'; +import { toast } from '@lace/common'; +import { MainFooter, MainHeader } from '../MainMenu'; +import styles from './MainLayout.module.scss'; +import { SimpleHeader } from '../MainMenu/SimpleHeader'; +import { useNetworkError } from '@hooks/useNetworkError'; +import { Announcement } from '@components/Announcement/Announcement'; +import { storage } from 'webextension-polyfill'; +import { ABOUT_EXTENSION_KEY, ExtensionUpdateData } from '@lib/scripts/types'; + +interface MainLayoutProps { + children: React.ReactNode; + useSimpleHeader?: boolean; + hideFooter?: boolean; + showAnnouncement?: boolean; + showBetaPill?: boolean; +} + +const toastThrottle = 500; + +export const extensionScrollableContainerID = 'extensionScrollable'; + +// TODO: fix styles. with html tag the coin and activity list disappear, +// same if the children is wrapped with ion-content. +// the fragment fix this but is positioning everything at the bottom. +// redo with antd +export const MainLayout = ({ + children, + useSimpleHeader = false, + hideFooter, + showAnnouncement = true, + showBetaPill = false +}: MainLayoutProps): React.ReactElement => { + const { t } = useTranslation(); + const [aboutExtension, setAboutExtension] = useState({} as ExtensionUpdateData); + const { version, acknowledged, reason } = aboutExtension; + + const debouncedToast = useMemo(() => debounce(toast.notify, toastThrottle), []); + const showNetworkError = useCallback( + () => debouncedToast({ text: t('general.errors.networkError') }), + [debouncedToast, t] + ); + + useNetworkError(showNetworkError); + + const getAboutExtensionData = useCallback(async () => { + const data = await storage.local.get(ABOUT_EXTENSION_KEY); + setAboutExtension(data?.[ABOUT_EXTENSION_KEY] || {}); + }, []); + + useEffect(() => { + getAboutExtensionData(); + }, [getAboutExtensionData]); + + const onUpdateAknowledge = useCallback(async () => { + const data = { version, acknowledged: true, reason }; + await storage.local.set({ + [ABOUT_EXTENSION_KEY]: data + }); + setAboutExtension(data); + }, [reason, version]); + + return ( +
+
+
+ {useSimpleHeader ? : } +
+ {children} +
+
+
+ + {!hideFooter && } +
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/Layout/PageTitle.module.scss b/apps/browser-extension-wallet/src/components/Layout/PageTitle.module.scss new file mode 100644 index 0000000000..e506294c65 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Layout/PageTitle.module.scss @@ -0,0 +1,24 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../packages/common/src/ui/styles/abstracts/typography'; + +.pageTitle { + @include text-heading; + color: var(--text-color-primary); + gap: size_unit(1); + margin: 0; + padding-bottom: size_unit(6); + + .amount { + color: var(--text-color-secondary); + font-size: var(--bodyLarge); + font-weight: 600; + line-height: size_unit(5); + margin-left: size_unit(1); + } +} + +@media (max-width: $breakpoint-popup) { + .pageTitle { + padding-bottom: size_unit(3); + } +} diff --git a/apps/browser-extension-wallet/src/components/Layout/PageTitle.tsx b/apps/browser-extension-wallet/src/components/Layout/PageTitle.tsx new file mode 100644 index 0000000000..514d4b9900 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Layout/PageTitle.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import isNil from 'lodash/isNil'; +import styles from './PageTitle.module.scss'; + +export interface PageTitleProps { + children: React.ReactNode; + amount?: string | number; + 'data-testid'?: string; +} + +export const PageTitle = ({ children, amount, ...rest }: PageTitleProps): React.ReactElement => ( +

+ {children} + {!isNil(amount) && ( + + ({amount}) + + )} +

+); diff --git a/apps/browser-extension-wallet/src/components/Layout/SectionTitle.module.scss b/apps/browser-extension-wallet/src/components/Layout/SectionTitle.module.scss new file mode 100644 index 0000000000..8b47947e65 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Layout/SectionTitle.module.scss @@ -0,0 +1,47 @@ +@import '../../styles/utils/functions.scss'; +@import '../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../packages/common/src/ui/styles/abstracts/typography'; + +.sectionTitleContainer { + display: flex; + align-items: center; + justify-content: flex-start; + position: sticky; + gap: size_unit(1); + margin-bottom: size_unit(7); + + @media (max-width: $breakpoint-popup) { + margin-bottom: size_unit(2) !important; + } + + h1 { + color: var(--text-color-primary); + font-size: var(--heading); + font-weight: $font-weight-bold; + line-height: size_unit(4); + margin: 0; + } + .icon { + margin-right: size_unit(1); + } +} + +.sectionTitle { + display: flex; + justify-content: flex-start; + gap: size_unit(1); + align-items: baseline; + + .title { + color: var(--text-color-primary); + @include text-heading; + } + .sideText { + color: var(--text-color-secondary); + @include text-bodyLarge-semi-bold; + } +} + +.sectionTitleContainerPopup { + margin-left: size_unit(3); +} diff --git a/apps/browser-extension-wallet/src/components/Layout/SectionTitle.tsx b/apps/browser-extension-wallet/src/components/Layout/SectionTitle.tsx new file mode 100644 index 0000000000..5cf07441c7 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Layout/SectionTitle.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import classnames from 'classnames'; +import styles from './SectionTitle.module.scss'; +import { Button, Typography } from 'antd'; +import { ArrowLeftOutlined } from '@ant-design/icons'; + +const { Text } = Typography; + +export interface SectionTitleProps { + title: string | React.ReactElement; + withIcon?: boolean; + sideText?: string | React.ReactElement; + handleIconClick?: () => void; + isPopup?: boolean; + classname?: string; + 'data-testid'?: string; +} + +export const SectionTitle = ({ + title, + withIcon, + handleIconClick, + sideText, + isPopup, + classname, + ...rest +}: SectionTitleProps): React.ReactElement => ( +
+ {withIcon && ( +
+); diff --git a/apps/browser-extension-wallet/src/components/Layout/__tests__/ContentLayout.test.tsx b/apps/browser-extension-wallet/src/components/Layout/__tests__/ContentLayout.test.tsx new file mode 100644 index 0000000000..4037f6afc9 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Layout/__tests__/ContentLayout.test.tsx @@ -0,0 +1,106 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import * as React from 'react'; +import { render, within, queryByText, fireEvent } from '@testing-library/react'; +import { ContentLayout } from '../ContentLayout'; +import '@testing-library/jest-dom'; +import { MemoryRouter } from 'react-router-dom'; +import { ReactElement } from 'react'; + +jest.mock('react-router', () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...jest.requireActual('react-router'), + useLocation: jest.fn().mockReturnValue({ pathname: '/wallet/delegate' }) +})); + +const testTitle = 'content title'; + +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +describe('Testing ContentLayout component', () => { + window.ResizeObserver = ResizeObserver; + beforeEach(() => { + jest.resetAllMocks(); + }); + + const WrappedContentLayout = ({ children }: { children: ReactElement }) => ( + {children} + ); + + const mockBackAction = jest.fn(); + + test('should render content layout', async () => { + const { findByTestId } = render( + + +
content
+
+
+ ); + + await findByTestId('content-layout'); + }); + + test('should render title', async () => { + const { findByTestId } = render( + + +
content
+
+
+ ); + + const content = await findByTestId('content-layout'); + const title = await within(content).findByTestId('section-title'); + + expect(title).toHaveTextContent(testTitle); + }); + + test('should render title and back button', async () => { + const { findByTestId } = render( + + +
content
+
+
+ ); + + const content = await findByTestId('content-layout'); + const icon = await within(content).findByTestId('section-title-btn-icon'); + + expect(icon).toBeInTheDocument(); + }); + + test('should not render content', async () => { + const { findByTestId } = render( + + +
content
+
+
+ ); + + const content = await findByTestId('content-layout'); + + expect(queryByText(content, 'content')).not.toBeInTheDocument(); + }); + + test('should fire action on icon click', async () => { + const { findByTestId } = render( + + +
content
+
+
+ ); + + const content = await findByTestId('content-layout'); + const btn = await within(content).findByTestId('section-title-btn-icon'); + fireEvent.click(btn); + + expect(mockBackAction).toHaveBeenCalled(); + }); +}); diff --git a/apps/browser-extension-wallet/src/components/Layout/index.ts b/apps/browser-extension-wallet/src/components/Layout/index.ts new file mode 100644 index 0000000000..353fb21808 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/Layout/index.ts @@ -0,0 +1,3 @@ +export * from './MainLayout'; +export * from './ContentLayout'; +export * from './PageTitle'; diff --git a/apps/browser-extension-wallet/src/components/MainLoader/MainLoader.module.scss b/apps/browser-extension-wallet/src/components/MainLoader/MainLoader.module.scss new file mode 100644 index 0000000000..5b66ba3b6e --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainLoader/MainLoader.module.scss @@ -0,0 +1,12 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; + +.loaderContainer { + @include flex-center; + flex-direction: column; + height: 100%; + gap: size_unit(2); +} + +.loader { + margin-left: 126px; +} diff --git a/apps/browser-extension-wallet/src/components/MainLoader/MainLoader.tsx b/apps/browser-extension-wallet/src/components/MainLoader/MainLoader.tsx new file mode 100644 index 0000000000..5e65f0234b --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainLoader/MainLoader.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Loader } from '@lace/common'; +import { useTranslation } from 'react-i18next'; +import styles from './MainLoader.module.scss'; + +export interface MainLoaderProps { + text?: string; +} + +export const MainLoader = ({ text }: MainLoaderProps): React.ReactElement => { + const { t } = useTranslation(); + + return ( +
+ +

{text ?? t('general.loading')}

+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/MainLoader/index.ts b/apps/browser-extension-wallet/src/components/MainLoader/index.ts new file mode 100644 index 0000000000..0384d453b5 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainLoader/index.ts @@ -0,0 +1 @@ +export * from './MainLoader'; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/DropdownMenuOverlay.module.scss b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/DropdownMenuOverlay.module.scss new file mode 100644 index 0000000000..d9902c611c --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/DropdownMenuOverlay.module.scss @@ -0,0 +1,169 @@ +@import '../../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../../packages/common/src/ui/styles/abstracts/typography'; + +.menuOverlay { + border-radius: size_unit(2) !important; + border: 1px var(--light-mode-light-grey) solid !important; + box-shadow: var(--shadows-toast-tooltip); + margin-top: size_unit(0.5) !important; + background-color: var(--bg-color-container, #ffffff) !important; + max-width: 258px; + width: 258px; + + .borderBottom { + border-bottom: 1px var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey, #efefef)) solid; + } + + .userInfoWrapper { + display: flex; + flex-direction: column; + padding: 10px size_unit(2) size_unit(2.75); + gap: size_unit(2); + + .userInfo { + display: flex; + align-items: center; + gap: size_unit(2); + margin: 0 -12px; + padding: size_unit(0.5) size_unit(1); + + &:hover { + background: var(--light-mode-light-grey, var(--dark-mode-mid-grey)); + border-radius: 12px; + } + + .userMeta { + cursor: pointer; + + .walletName { + margin: 0; + color: var(--text-color-primary); + @include text-body-semi-bold; + } + + .walletAddress { + margin: 0; + color: var(--light-mode-dark-grey, var(--dark-mode-light-grey, #a9a9a9)); + @include text-address($weight: 500); + } + } + } + } + + .walletStatusInfo { + cursor: default; + display: flex; + } + + .links { + margin: size_unit(2) 0; + + .menuItem, + .menuItemTheme { + @include text-body-medium; + padding: size_unit(2) size_unit(2) !important; + margin: 0 size_unit(1) !important; + border-radius: 12px !important; + background-color: var(--bg-color-container, #ffffff) !important; + color: var(--text-color-primary); + &:hover { + background: var(--light-mode-light-grey, var(--dark-mode-mid-grey, #efefef)) !important; + } + &.cta { + cursor: pointer; + } + } + .menuItemTheme { + :global { + button.ant-switch { + height: size_unit(3); + width: size_unit(5.5); + } + + .ant-switch > div.ant-switch-handle { + height: size_unit(2.5); + width: size_unit(2.5); + } + + .ant-switch > span.ant-switch-inner svg { + margin-top: 1px; + } + + .ant-switch.ant-switch-checked > span.ant-switch-inner { + margin: 0 size_unit(35) 0 size_unit(0.5); + } + + .ant-switch.ant-switch-checked > div.ant-switch-handle { + left: calc(100% - 20px - 2px); + } + } + + display: flex; + justify-content: space-between !important; + &:hover { + background: transparent !important; + } + } + } +} +.separator { + display: flex; + height: 1.5px; + background: var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey, #efefef)); + width: auto; + margin: size_unit(1.5) size_unit(3); +} + +.menuOverlay li:first-child { + background: transparent; + padding: 0px size_unit(1); +} + +.tooltip { + color: var(--text-color-secondary); + font-size: var(--bodySmall); + font-weight: 500; + line-height: size_unit(3); +} + +.userAvatar { + background: var(--primary-gradient); + border-radius: 100px; + height: size_unit(4); + width: size_unit(4); + display: flex; + align-items: center; + justify-content: center; + span { + color: var(--dark-mode-mid-black, var(--text-color-white, #ffffff)); + font-size: var(--body); + text-transform: uppercase; + font-weight: 700; + } + .avatar { + font-size: size_unit(4); + cursor: pointer; + } + + &.isPopup { + height: 26px; + width: 26px; + } +} + +.networkChoise { + align-items: center !important; + display: flex; + justify-content: space-between; + + .value { + color: var(--primary-default, #7f5af0); + display: flex; + flex: 1; + justify-content: flex-end; + } +} + +.switchIcon { + font-size: 16px; +} diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/DropdownMenuOverlay.tsx b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/DropdownMenuOverlay.tsx new file mode 100644 index 0000000000..4416924633 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/DropdownMenuOverlay.tsx @@ -0,0 +1,53 @@ +import React, { ReactNode, VFC, useState } from 'react'; +import { Menu, MenuProps } from 'antd'; +import { + Separator, + Links, + AddressBookLink, + SettingsLink, + ThemeSwitcher, + LockWallet, + UserInfo, + NetworkChoise +} from './components'; +import styles from './DropdownMenuOverlay.module.scss'; +import { NetworkInfo } from './components/NetworkInfo'; +import { Sections } from './types'; + +interface Props extends MenuProps { + isPopup?: boolean; + lockWalletButton?: ReactNode; + topSection?: ReactNode; +} + +export const DropdownMenuOverlay: VFC = ({ + isPopup, + lockWalletButton = , + topSection = , + ...props +}): React.ReactElement => { + const [currentSection, setCurrentSection] = useState(Sections.Main); + + return ( + + {currentSection === Sections.Main && ( + <> + {topSection} + + + + + + setCurrentSection(Sections.NetworkInfo)} /> + {lockWalletButton && ( + <> + {lockWalletButton} + + )} + + + )} + {currentSection === Sections.NetworkInfo && setCurrentSection(Sections.Main)} />} + + ); +}; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/AddressBookLink.tsx b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/AddressBookLink.tsx new file mode 100644 index 0000000000..e16dba6009 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/AddressBookLink.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { walletRoutePaths } from '@routes'; +import { Menu } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import styles from '../DropdownMenuOverlay.module.scss'; +import { useAnalyticsContext } from '@providers'; +import { + AnalyticsEventActions, + AnalyticsEventCategories, + AnalyticsEventNames +} from '@providers/AnalyticsProvider/analyticsTracker'; + +export const AddressBookLink = ({ isPopup }: { isPopup: boolean }): React.ReactElement => { + const { t } = useTranslation(); + const analytics = useAnalyticsContext(); + + const handleOnClicked = () => { + analytics.sendEvent({ + category: AnalyticsEventCategories.ADDRESS_BOOK, + action: AnalyticsEventActions.CLICK_EVENT, + name: isPopup + ? AnalyticsEventNames.AddressBook.VIEW_ADDRESSES_POPUP + : AnalyticsEventNames.AddressBook.VIEW_ADDRESSES_BROWSER + }); + }; + + return ( + + + {t('browserView.sideMenu.links.addressBook')} + + + ); +}; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/Links.tsx b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/Links.tsx new file mode 100644 index 0000000000..4f3e61664b --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/Links.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import styles from '../DropdownMenuOverlay.module.scss'; + +interface Props { + children: React.ReactNode; +} + +export const Links = ({ children }: Props): React.ReactElement =>
{children}
; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/LockWallet.tsx b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/LockWallet.tsx new file mode 100644 index 0000000000..0a2be84282 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/LockWallet.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { useWalletManager } from '@hooks'; +import { useWalletStore } from '@src/stores'; +import { Menu } from 'antd'; +import { useTranslation } from 'react-i18next'; +import styles from '../DropdownMenuOverlay.module.scss'; + +export const LockWallet = (): React.ReactElement => { + const { t } = useTranslation(); + const { lockWallet } = useWalletManager(); + const { walletLock } = useWalletStore(); + + return ( + + {t('browserView.topNavigationBar.links.lockWallet')} + + ); +}; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/NetworkChoise.tsx b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/NetworkChoise.tsx new file mode 100644 index 0000000000..61c81f4f7b --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/NetworkChoise.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import cn from 'classnames'; +import { useWalletStore } from '@src/stores'; +import styles from '../DropdownMenuOverlay.module.scss'; + +type NetworkChoiseProps = { + onClick: () => void; +}; + +export const NetworkChoise = ({ onClick }: NetworkChoiseProps): React.ReactElement => { + const { t } = useTranslation(); + const { environmentName } = useWalletStore(); + + return ( +
onClick()} + > +
+ {t('browserView.topNavigationBar.links.network')} + + {environmentName} + +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/NetworkInfo.module.scss b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/NetworkInfo.module.scss new file mode 100644 index 0000000000..8b881dff1a --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/NetworkInfo.module.scss @@ -0,0 +1,37 @@ +@import '../../../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../../../packages/common/src/ui/styles/abstracts/typography'; + +.container { + display: flex; + flex-direction: column; + padding: size_unit(3) size_unit(4) size_unit(3) 22px; + width: 16.25rem !important; +} + +.titleSection { + color: var(--text-color-primary) !important; + display: flex; + flex-direction: column; + gap: size_unit(2); + margin-top: size_unit(3); + .title { + @include text-body-semi-bold; + color: var(--text-color-primary) !important; + } + .subTitle { + @include text-body-medium; + color: var(--text-color-secondary) !important; + } +} + +.content { + display: flex; + flex-direction: column; + margin-top: size_unit(4); +} + +.iconClassName { + @media (max-width: $breakpoint-popup) { + font-size: 14px !important; + } +} diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/NetworkInfo.tsx b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/NetworkInfo.tsx new file mode 100644 index 0000000000..4a2859b311 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/NetworkInfo.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { NavigationButton } from '@lace/common'; +import styles from './NetworkInfo.module.scss'; +import { NetworkChoice } from '@src/features/settings'; + +type NetworkChoiseProps = { + onBack: () => void; +}; + +export const NetworkInfo = ({ onBack }: NetworkChoiseProps): React.ReactElement => { + const { t } = useTranslation(); + + return ( +
+
+ +
+
+
+ {t('browserView.settings.wallet.network.title')} +
+
+ {t('browserView.settings.wallet.network.drawerDescription')} +
+
+
+ +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/Separator.tsx b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/Separator.tsx new file mode 100644 index 0000000000..e6e7c34568 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/Separator.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +import styles from '../DropdownMenuOverlay.module.scss'; + +export const Separator = (): React.ReactElement =>
; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/SettingsLink.tsx b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/SettingsLink.tsx new file mode 100644 index 0000000000..fc488cb126 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/SettingsLink.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { walletRoutePaths } from '@routes'; +import { Menu } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import styles from '../DropdownMenuOverlay.module.scss'; + +export const SettingsLink = (): React.ReactElement => { + const { t } = useTranslation(); + + return ( + + + {t('browserView.topNavigationBar.links.settings')} + + + ); +}; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/ThemeSwitcher.tsx b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/ThemeSwitcher.tsx new file mode 100644 index 0000000000..caac8bac4a --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/ThemeSwitcher.tsx @@ -0,0 +1,54 @@ +/* eslint-disable react/no-multi-comp */ +import React from 'react'; +import { Switch } from '@lace/common'; +import { useTranslation } from 'react-i18next'; +import styles from '../DropdownMenuOverlay.module.scss'; +import { useTheme } from '@providers/ThemeProvider/context'; +import SunIcon from '../../../../assets/icons/sun.component.svg'; +import MoonIcon from '../../../../assets/icons/moon.component.svg'; +import { useBackgroundServiceAPIContext } from '@providers/BackgroundServiceAPI'; + +const modeTranslate: Record = { + light: 'browserView.sideMenu.mode.light', + dark: 'browserView.sideMenu.mode.dark' +}; + +interface Props { + isPopup?: boolean; +} + +export const ThemeSwitch = ({ isPopup }: Props): React.ReactElement => { + const { theme, setTheme } = useTheme(); + const backgroundServices = useBackgroundServiceAPIContext(); + + const handleCurrentTheme = () => { + const pickedTheme = theme.name === 'light' ? 'dark' : 'light'; + setTheme(pickedTheme); + + if (isPopup) { + backgroundServices.handleChangeTheme({ theme: pickedTheme }); + } + }; + + return ( + } + unCheckedChildren={} + checked={theme.name === 'light'} + onChange={handleCurrentTheme} + /> + ); +}; + +export const ThemeSwitcher = ({ isPopup }: Props): React.ReactElement => { + const { theme } = useTheme(); + const { t } = useTranslation(); + + return ( +
+ {t(modeTranslate[theme.name])} + +
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/UserAvatar.tsx b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/UserAvatar.tsx new file mode 100644 index 0000000000..6c7b82c055 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/UserAvatar.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import cn from 'classnames'; +import styles from '../DropdownMenuOverlay.module.scss'; + +export const UserAvatar = ({ walletName, isPopup }: { walletName: string; isPopup?: boolean }): React.ReactElement => ( +
+ {walletName[0]?.toUpperCase()} +
+); diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/UserInfo.tsx b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/UserInfo.tsx new file mode 100644 index 0000000000..7903bdbf3d --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/UserInfo.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import classnames from 'classnames'; +import { useWalletStore } from '@src/stores'; +import { Menu, Tooltip as AntdTooltip } from 'antd'; +import { useTranslation } from 'react-i18next'; +import styles from '../DropdownMenuOverlay.module.scss'; +import { CopyToClipboard } from 'react-copy-to-clipboard'; +import { toast, addEllipsis } from '@lace/common'; +import { WalletStatusContainer } from '@components/WalletStatus'; +import { UserAvatar } from './UserAvatar'; + +const ADRESS_FIRST_PART_LENGTH = 10; +const ADRESS_LAST_PART_LENGTH = 5; +const WALLET_NAME_MAX_LENGTH = 16; +const TOAST_DEFAULT_DURATION = 3; + +const overlayInnerStyle = { + padding: '8px 16px', + borderRadius: '12px', + boxShadow: 'box-shadow: 0px 0px 16px rgba(167, 143, 160, 0.2)' +}; + +interface UserInfoProps { + avatarVisible?: boolean; +} + +export const UserInfo = ({ avatarVisible = true }: UserInfoProps): React.ReactElement => { + const { t } = useTranslation(); + const { walletInfo } = useWalletStore(); + const walletAddress = walletInfo.address.toString(); + const shortenedWalletAddress = addEllipsis(walletAddress, ADRESS_FIRST_PART_LENGTH, ADRESS_LAST_PART_LENGTH); + const walletName = addEllipsis(walletInfo.name.toString(), WALLET_NAME_MAX_LENGTH, 0); + + return ( + +
+ + {t('settings.copyAddress')}} + > +
+ toast.notify({ duration: TOAST_DEFAULT_DURATION, text: t('general.clipboard.copiedToClipboard') }) + } + > + {avatarVisible && } +
+

+ {walletName} +

+

+ {shortenedWalletAddress} +

+
+
+
+
+
+ +
+
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/index.ts b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/index.ts new file mode 100644 index 0000000000..040f0a990c --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/index.ts @@ -0,0 +1,10 @@ +export * from './Links'; +export * from './Separator'; +export * from './AddressBookLink'; +export * from './SettingsLink'; +export * from './ThemeSwitcher'; +export * from './LockWallet'; +export * from './UserInfo'; +export * from './UserAvatar'; +export * from './NetworkChoise'; +export * from './NetworkInfo'; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/index.ts b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/index.ts new file mode 100644 index 0000000000..68101baad4 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/index.ts @@ -0,0 +1 @@ +export { DropdownMenuOverlay } from './DropdownMenuOverlay'; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/types.ts b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/types.ts new file mode 100644 index 0000000000..c9b8595a2c --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/types.ts @@ -0,0 +1,4 @@ +export enum Sections { + Main = 'main', + NetworkInfo = 'network_info' +} diff --git a/apps/browser-extension-wallet/src/components/MainMenu/MainFooter.module.scss b/apps/browser-extension-wallet/src/components/MainMenu/MainFooter.module.scss new file mode 100644 index 0000000000..bca4e02ded --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/MainFooter.module.scss @@ -0,0 +1,38 @@ +@import '../../styles/utils/functions.scss'; + +.footer { + width: 100%; + display: flex; + border-top: 2px solid var(--light-mode-light-grey-plus, transparent); + z-index: 1; + background-color: var(--dark-mode-mid-black); + height: 64px; + + .content { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + padding: 20px size_unit(5); + + .icon { + height: size_unit(2.125); + width: size_unit(2.125); + } + + button { + background: transparent; + border: none; + cursor: pointer; + font-size: size_unit(3); + padding: 0; + display: flex; + color: var(--dark-mode-light-grey, var(--text-color-primary, #3d3b39)); + + &:focus { + outline: none; + box-shadow: none; + } + } + } +} diff --git a/apps/browser-extension-wallet/src/components/MainMenu/MainFooter.tsx b/apps/browser-extension-wallet/src/components/MainMenu/MainFooter.tsx new file mode 100644 index 0000000000..a0c0a22973 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/MainFooter.tsx @@ -0,0 +1,132 @@ +import React, { useState } from 'react'; +import { useLocation, useHistory } from 'react-router-dom'; +import { walletRoutePaths } from '../../routes'; + +import NftIconDefault from '../../assets/icons/nft-icon.component.svg'; +import NftIconActive from '../../assets/icons/active-nft-icon.component.svg'; +import NftIconActiveHover from '../../assets/icons/hover-nft-icon.component.svg'; + +import AssetsIconDefault from '../../assets/icons/assets-icon.component.svg'; +import AssetsIconActive from '../../assets/icons/active-assets-icon.component.svg'; +import AssetIconHover from '../../assets/icons/hover-assets-icon.component.svg'; + +import StakingIconActive from '../../assets/icons/active-database-icon.component.svg'; +import StakingIconDefault from '../../assets/icons/database-icon.component.svg'; +import StakingIconHover from '../../assets/icons/hover-database-icon.component.svg'; + +import TransactionsIconDefault from '../../assets/icons/transactions-icon.component.svg'; +import TransactionsIconActive from '../../assets/icons/active-transactions-icon.component.svg'; +import TransactionsIconHover from '../../assets/icons/hover-transactions-icon.component.svg'; +import { MenuItemList } from '@src/utils/constants'; +import styles from './MainFooter.module.scss'; +import { useAnalyticsContext } from '@providers'; +import { + AnalyticsEventActions, + AnalyticsEventCategories, + AnalyticsEventNames +} from '@providers/AnalyticsProvider/analyticsTracker'; + +const includesCoin = /coin/i; + +export const MainFooter = (): React.ReactElement => { + const location = useLocation<{ pathname: string }>(); + const history = useHistory(); + const analytics = useAnalyticsContext(); + + const currentLocation = location?.pathname; + const isWalletIconActive = + currentLocation === walletRoutePaths.assets || includesCoin.test(currentLocation) || currentLocation === '/'; + + const [currentHoveredItem, setCurrentHoveredItem] = useState(); + const onMouseEnterItem = (item: MenuItemList) => { + setCurrentHoveredItem(item); + }; + + // eslint-disable-next-line unicorn/no-useless-undefined + const onMouseLeaveItem = () => setCurrentHoveredItem(undefined); + + const AssetsIcon = currentHoveredItem === MenuItemList.ASSETS ? AssetIconHover : AssetsIconDefault; + const NftIcon = currentHoveredItem === MenuItemList.NFT ? NftIconActiveHover : NftIconDefault; + const TransactionsIcon = + currentHoveredItem === MenuItemList.TRANSACTIONS ? TransactionsIconHover : TransactionsIconDefault; + const StakingIcon = currentHoveredItem === MenuItemList.STAKING ? StakingIconHover : StakingIconDefault; + + const sendAnalytics = (category: AnalyticsEventCategories, name: string) => { + analytics.sendEvent({ + category, + action: AnalyticsEventActions.CLICK_EVENT, + name + }); + }; + + const handleNavigation = (path: string) => { + switch (path) { + case walletRoutePaths.assets: + sendAnalytics(AnalyticsEventCategories.VIEW_TOKENS, AnalyticsEventNames.ViewTokens.VIEW_TOKEN_LIST_POPUP); + break; + case walletRoutePaths.earn: + sendAnalytics(AnalyticsEventCategories.STAKING, AnalyticsEventNames.Staking.VIEW_STAKING_POPUP); + break; + case walletRoutePaths.activity: + sendAnalytics( + AnalyticsEventCategories.VIEW_TRANSACTIONS, + AnalyticsEventNames.ViewTransactions.VIEW_TX_LIST_POPUP + ); + break; + case walletRoutePaths.nfts: + sendAnalytics(AnalyticsEventCategories.VIEW_NFT, AnalyticsEventNames.ViewNFTs.VIEW_NFT_LIST_POPUP); + } + history.push(path); + }; + + return ( +
+
+ + + + +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/MainHeader.module.scss b/apps/browser-extension-wallet/src/components/MainMenu/MainHeader.module.scss new file mode 100644 index 0000000000..cb96dbc1c8 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/MainHeader.module.scss @@ -0,0 +1,38 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; + +.header { + width: 100%; + display: flex; + background-color: var(--bg-color-body, #fff); + padding-left: 1px; + padding-right: 1px; + + .content { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + margin: size_unit(1.75) size_unit(3); + .linkLogo { + display: flex; + align-items: center; + justify-content: center; + } + .logo { + font-size: var(--heading); + height: size_unit(4.5); + width: size_unit(4.5); + } + + .controls { + display: flex; + align-items: center; + gap: size_unit(1); + + .avatar { + font-size: 20px; + cursor: pointer; + } + } + } +} diff --git a/apps/browser-extension-wallet/src/components/MainMenu/MainHeader.tsx b/apps/browser-extension-wallet/src/components/MainMenu/MainHeader.tsx new file mode 100644 index 0000000000..c68461f242 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/MainHeader.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import styles from './MainHeader.module.scss'; +import LaceLogoMark from '../../assets/branding/lace-logo-mark.component.svg'; +import { useTranslation } from 'react-i18next'; +import { walletRoutePaths } from '@routes'; + +import { DropdownMenu } from '@components/DropdownMenu'; +import { ExpandButton } from '@components/ExpandButton'; +import { useBackgroundServiceAPIContext } from '@providers/BackgroundServiceAPI'; +import { BrowserViewSections } from '@lib/scripts/types'; +import { NetworkPill } from '@components/NetworkPill'; + +export const MainHeader = (): React.ReactElement => { + const { t } = useTranslation(); + const backgroundServices = useBackgroundServiceAPIContext(); + const location = useLocation(); + + const locationBrowserSection = { + [walletRoutePaths.assets]: BrowserViewSections.HOME, + [walletRoutePaths.nfts]: BrowserViewSections.NFTS, + [walletRoutePaths.activity]: BrowserViewSections.TRANSACTION, + [walletRoutePaths.earn]: BrowserViewSections.STAKING, + [walletRoutePaths.addressBook]: BrowserViewSections.ADDRESS_BOOK, + [walletRoutePaths.settings]: BrowserViewSections.SETTINGS + }; + + return ( +
+
+ + + + +
+ backgroundServices.handleOpenBrowser({ section: locationBrowserSection[location.pathname] })} + /> + +
+
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/SimpleHeader.tsx b/apps/browser-extension-wallet/src/components/MainMenu/SimpleHeader.tsx new file mode 100644 index 0000000000..722fe3286f --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/SimpleHeader.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import styles from './MainHeader.module.scss'; +import laceLogoMark from '@src/assets/branding/lace-logo-mark.svg'; +import { BetaPill } from '@src/features/dapp'; + +interface SimpleHeaderProps { + showBetaPill?: boolean; +} + +export const SimpleHeader = ({ showBetaPill = false }: SimpleHeaderProps): React.ReactElement => ( +
+
+
+ LACE +
+ {showBetaPill && } +
+
+); diff --git a/apps/browser-extension-wallet/src/components/MainMenu/UserAvatar.module.scss b/apps/browser-extension-wallet/src/components/MainMenu/UserAvatar.module.scss new file mode 100644 index 0000000000..c1a971c503 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/UserAvatar.module.scss @@ -0,0 +1,20 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; + +$size: size_unit(4); + +.root { + align-items: center; + background: var(--primary-gradient); + border-radius: $size; + display: flex; + height: $size; + justify-content: center; + width: $size; + + .letter { + color: var(--text-color-white, #ffffff); + font-size: var(--body); + font-weight: 600; + text-transform: uppercase; + } +} diff --git a/apps/browser-extension-wallet/src/components/MainMenu/UserAvatar.tsx b/apps/browser-extension-wallet/src/components/MainMenu/UserAvatar.tsx new file mode 100644 index 0000000000..899ee83083 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/UserAvatar.tsx @@ -0,0 +1,15 @@ +import { useWalletStore } from '@stores'; +import React, { VFC } from 'react'; +import styles from './UserAvatar.module.scss'; + +export const UserAvatar: VFC = () => { + const { + walletInfo: { name } + } = useWalletStore(); + + return ( +
+ {name[0]} +
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/MainMenu/index.ts b/apps/browser-extension-wallet/src/components/MainMenu/index.ts new file mode 100644 index 0000000000..e1e92d5419 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MainMenu/index.ts @@ -0,0 +1,4 @@ +export * from './MainHeader'; +export * from './MainFooter'; +export * from './DropdownMenuOverlay'; +export * from './UserAvatar'; diff --git a/apps/browser-extension-wallet/src/components/MigrationContainer/FailedMigration.module.scss b/apps/browser-extension-wallet/src/components/MigrationContainer/FailedMigration.module.scss new file mode 100644 index 0000000000..e552fd916f --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MigrationContainer/FailedMigration.module.scss @@ -0,0 +1,12 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; + +.failedMigrationContainer { + @include flex-center; + height: 100%; + flex-direction: column; + gap: size_unit(2); +} + +.failedMigrationButton { + margin-top: 20px; +} \ No newline at end of file diff --git a/apps/browser-extension-wallet/src/components/MigrationContainer/FailedMigration.tsx b/apps/browser-extension-wallet/src/components/MigrationContainer/FailedMigration.tsx new file mode 100644 index 0000000000..fb6d1ffcd9 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MigrationContainer/FailedMigration.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@lace/common'; +import { ResultMessage } from '@components/ResultMessage'; +import styles from './FailedMigration.module.scss'; +import { AppMode } from '@src/utils/constants'; +import { WalletSetupLayout } from '@src/views/browser-view/components'; +import { useWalletManager } from '@hooks'; +import { useWalletStore } from '@src/stores'; +import { useBackgroundServiceAPIContext, useTheme } from '@providers'; + +export interface FailedMigrationProps { + appMode: AppMode; +} + +export const FailedMigration = ({ appMode }: FailedMigrationProps): React.ReactElement => { + const { t } = useTranslation(); + const { deleteWallet } = useWalletManager(); + const { walletManagerUi } = useWalletStore(); + const { theme } = useTheme(); + const backgroundService = useBackgroundServiceAPIContext(); + + const Layout = appMode === 'browser' ? WalletSetupLayout : React.Fragment; + + const resetData = async () => { + if (walletManagerUi) await deleteWallet(); + window.localStorage.clear(); + window.localStorage.setItem('mode', theme.name); + await backgroundService.resetStorage(); + // Reload app states with updated storage + window.location.reload(); + }; + + return ( + +
+ +
{t('migrations.failed.title')}
+
{t('migrations.failed.subtitle')}
+ + } + description={ + <> +
{t('migrations.failed.errorDescription')}
+
+ {t('migrations.failed.actionDescription')} +
+ + } + /> +
+ +
+
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/MigrationContainer/MigrationContainer.tsx b/apps/browser-extension-wallet/src/components/MigrationContainer/MigrationContainer.tsx new file mode 100644 index 0000000000..5ce32d483c --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MigrationContainer/MigrationContainer.tsx @@ -0,0 +1,151 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { storage, Storage } from 'webextension-polyfill'; +import { applyMigrations, migrationsRequirePassword } from '@lib/scripts/migrations'; +import { MigrationState } from '@lib/scripts/types'; +import { UnlockWallet } from '@src/features/unlock-wallet'; +import { useWalletManager } from '@hooks'; +import { AppMode, APP_MODE_POPUP } from '@src/utils/constants'; +import { Lock } from '@src/views/browser-view/components/Lock'; +import { MainLoader } from '@components/MainLoader'; +import { FailedMigration } from './FailedMigration'; +import { MigrationInProgress } from './MigrationInProgress'; + +export interface MigrationContainerProps { + children: React.ReactNode; + appMode: AppMode; +} + +interface RenderState { + isMigrating: boolean; + locked: boolean; + didMigrationFail: boolean; +} + +const INITIAL_RENDER_STATE = { locked: false, didMigrationFail: false, isMigrating: true }; + +export const MigrationContainer = ({ children, appMode }: MigrationContainerProps): React.ReactElement => { + const { unlockWallet } = useWalletManager(); + + const [isLoadingFirstTime, setIsLoadingFirstTime] = useState(false); + const [migrationState, setMigrationState] = useState(); + const [renderState, setRenderState] = useState(INITIAL_RENDER_STATE); + + const [isVerifyingPassword, setIsVerifyingPassword] = useState(false); + const [password, setPassword] = useState(); + const [isValidPassword, setIsValidPassword] = useState(true); + + const migrate = useCallback(async () => { + setRenderState(INITIAL_RENDER_STATE); + if (appMode === APP_MODE_POPUP) await applyMigrations(migrationState, password); + }, [migrationState, password, appMode]); + + const handlePasswordChange = useCallback( + ({ target: { value } }: React.ChangeEvent) => { + if (!isValidPassword) { + setIsValidPassword(true); + } + setPassword(value); + }, + [isValidPassword] + ); + + const lockAndMigrate = useCallback(async () => { + const shouldLock = await migrationsRequirePassword(migrationState); + if (shouldLock) { + setRenderState({ didMigrationFail: false, locked: true, isMigrating: true }); + return; + } + await migrate(); + }, [migrate, migrationState]); + + const onUnlock = useCallback(async (): Promise => { + setIsVerifyingPassword(true); + try { + await unlockWallet(password); + setIsValidPassword(true); + await migrate(); + } catch { + setIsValidPassword(false); + } + setIsVerifyingPassword(false); + }, [password, unlockWallet, migrate]); + + useEffect(() => { + // Load initial migrationState value + storage.local + .get('MIGRATION_STATE') + .then((value) => { + setIsLoadingFirstTime(true); + setMigrationState(value.MIGRATION_STATE as MigrationState); + }) + .catch((error) => console.log('Error fetching initial migration state:', error)); + + // Observe changes to MIGRATION_STATE in storage + const observeMigrationState = (changes: Record) => { + if (changes.MIGRATION_STATE && changes.MIGRATION_STATE.newValue !== changes.MIGRATION_STATE.oldValue) { + setIsLoadingFirstTime(false); + setMigrationState(changes.MIGRATION_STATE.newValue as MigrationState); + } + }; + if (!storage.onChanged.hasListener(observeMigrationState)) storage.onChanged.addListener(observeMigrationState); + + return () => { + storage.onChanged.removeListener(observeMigrationState); + }; + }, []); + + useEffect(() => { + // TODO: refactor with `useReducer` [LW-6494] + (async () => { + if (!migrationState) return; + switch (migrationState.state) { + case 'not-applied': { + await lockAndMigrate(); + break; + } + case 'not-loaded': { + setRenderState(INITIAL_RENDER_STATE); + break; + } + case 'migrating': { + if (isLoadingFirstTime) { + // This means an update was interrupted while migrating last time the app was opened + await lockAndMigrate(); + break; + } + setRenderState(INITIAL_RENDER_STATE); + break; + } + case 'error': { + setRenderState({ didMigrationFail: true, locked: false, isMigrating: false }); + break; + } + case 'up-to-date': { + setRenderState({ didMigrationFail: false, locked: false, isMigrating: false }); + } + } + })(); + }, [migrationState, lockAndMigrate, isLoadingFirstTime]); + + if (renderState.didMigrationFail) return ; + + if (renderState.locked) { + return appMode === APP_MODE_POPUP ? ( + + ) : ( + + ); + } + + if (renderState.isMigrating) { + return migrationState?.state !== 'not-loaded' ? : ; + } + + return <>{children}; +}; diff --git a/apps/browser-extension-wallet/src/components/MigrationContainer/MigrationInProgress.module.scss b/apps/browser-extension-wallet/src/components/MigrationContainer/MigrationInProgress.module.scss new file mode 100644 index 0000000000..c57afc5d60 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MigrationContainer/MigrationInProgress.module.scss @@ -0,0 +1,37 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../packages/common/src/ui/styles/abstracts/typography'; + +.migrationInProgressContainer { + @include flex-center; + height: 100%; + flex-direction: column; + gap: size_unit(2); + + .mark { + width: 80px; + } + + .title { + @include text-heading; + color: var(--text-color-primary); + } + + .description { + @include flex-center; + @include text-body-semi-bold; + flex-direction: column; + gap: size_unit(2); + color: var(--text-color-secondary) !important; + text-align: center; + margin-bottom: 0 !important; + max-width: 398px; + + @media (max-width: $breakpoint-popup) { + max-width: 260px; + padding: 0; + font-size: var(--body); + line-height: size_unit(3); + margin-bottom: 0; + } + } +} diff --git a/apps/browser-extension-wallet/src/components/MigrationContainer/MigrationInProgress.tsx b/apps/browser-extension-wallet/src/components/MigrationContainer/MigrationInProgress.tsx new file mode 100644 index 0000000000..f7a57c0be7 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MigrationContainer/MigrationInProgress.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import laceLogoMark from '@src/assets/branding/lace-logo-mark.svg'; +import styles from './MigrationInProgress.module.scss'; +import { WalletSetupLayout } from '@src/views/browser-view/components'; +import { AppMode, APP_MODE_POPUP } from '@src/utils/constants'; +import { MainLoader } from '@components/MainLoader'; + +export interface MigrationInProgressProps { + appMode: AppMode; +} + +export const MigrationInProgress = ({ appMode }: MigrationInProgressProps): React.ReactElement => { + const { t } = useTranslation(); + + return appMode === APP_MODE_POPUP ? ( + + ) : ( + +
+ LACE +

{t('migrations.inProgress.browser.title')}

+
+
{t('migrations.inProgress.browser.description.1')}
+
+ {t('migrations.inProgress.browser.description.2')} +
+
+
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/MigrationContainer/__tests__/MigrationContainer.test.tsx b/apps/browser-extension-wallet/src/components/MigrationContainer/__tests__/MigrationContainer.test.tsx new file mode 100644 index 0000000000..9062c337ac --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MigrationContainer/__tests__/MigrationContainer.test.tsx @@ -0,0 +1,187 @@ +/* eslint-disable sonarjs/no-identical-functions */ +/* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable import/imports-first */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const mockUseWalletManager = { + unlockWallet: jest.fn(), + deleteWallet: jest.fn() +}; +const mockMigrations = { + applyMigrations: jest.fn(), + migrationsRequirePassword: jest.fn().mockResolvedValue(false) +}; +import React from 'react'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import { storage } from 'webextension-polyfill'; +import '@testing-library/jest-dom'; +import { MigrationContainer } from '../MigrationContainer'; +import { APP_MODE_BROWSER, APP_MODE_POPUP } from '@src/utils/constants'; +import { ExternalLinkOpenerProvider, ThemeProvider } from '@providers'; + +jest.mock('../../../hooks', () => ({ + ...jest.requireActual('../../../hooks'), + useWalletManager: jest.fn().mockReturnValue(mockUseWalletManager) +})); +jest.mock('../../../stores', () => ({ + ...jest.requireActual('../../../stores'), + useWalletStore: jest.fn().mockReturnValue({}) +})); +jest.mock('../../../lib/scripts/migrations', () => ({ + ...jest.requireActual('../../../lib/scripts/migrations'), + ...mockMigrations +})); +jest.mock('../../../providers', () => ({ + ...jest.requireActual('../../../providers'), + useBackgroundServiceAPIContext: jest.fn().mockReturnValue({}) +})); + +const MigrationContainerTest = ({ appMode = APP_MODE_POPUP }) => ( + + + +
App
+
+
+
+); + +describe('MigrationContainer', () => { + beforeEach(async () => { + await storage.local.clear(); + jest.clearAllMocks(); + }); + afterEach(() => { + cleanup(); + }); + describe('When migration state on first render is', () => { + describe('up-to-date', () => { + beforeEach(async () => { + await storage.local.set({ MIGRATION_STATE: { state: 'up-to-date' } }); + }); + test('renders children', async () => { + const { queryByTestId } = render(); + await waitFor(() => expect(queryByTestId('mock-child')).toBeInTheDocument()); + await waitFor(() => expect(mockMigrations.applyMigrations).not.toHaveBeenCalled()); + }); + }); + + describe('not-loaded', () => { + beforeEach(async () => { + await storage.local.set({ MIGRATION_STATE: { state: 'not-loaded' } }); + }); + test('renders MainLoader screen with default message', async () => { + const { queryByTestId } = render(); + + await waitFor(() => expect(queryByTestId('mock-child')).not.toBeInTheDocument()); + await waitFor(() => { + const mainLoader = queryByTestId('main-loader'); + expect(mainLoader).toBeInTheDocument(); + expect(mainLoader).toHaveTextContent('Loading...'); + }); + await waitFor(() => expect(mockMigrations.applyMigrations).not.toHaveBeenCalled()); + }); + }); + + describe('migrating', () => { + beforeEach(async () => { + await storage.local.set({ MIGRATION_STATE: { state: 'migrating', from: '1.0.0', to: '2.0.0' } }); + }); + describe('in popup mode', () => { + test('if password not required, renders MainLoader screen with applying update message and applies migrations', async () => { + const { queryByTestId } = render(); + + await waitFor(() => expect(queryByTestId('mock-child')).not.toBeInTheDocument()); + await waitFor(() => { + const mainLoader = queryByTestId('main-loader'); + expect(mainLoader).toBeInTheDocument(); + expect(mainLoader).toHaveTextContent('Applying update...'); + }); + await waitFor(() => expect(mockMigrations.migrationsRequirePassword).toHaveBeenCalled()); + await waitFor(() => expect(mockMigrations.applyMigrations).toHaveBeenCalled()); + }); + test('if password required, renders unlock screen and does not applies migrations', async () => { + mockMigrations.migrationsRequirePassword.mockResolvedValueOnce(true); + const { queryByTestId } = render(); + + await waitFor(() => expect(queryByTestId('mock-child')).not.toBeInTheDocument()); + await waitFor(() => { + const unlock = queryByTestId('unlock-screen'); + expect(unlock).toBeInTheDocument(); + }); + await waitFor(() => expect(mockMigrations.migrationsRequirePassword).toHaveBeenCalled()); + await waitFor(() => expect(mockMigrations.applyMigrations).not.toHaveBeenCalled()); + }); + }); + describe('in browser mode', () => { + test('if password not required, renders migration in progress screen and does not apply migrations', async () => { + const { queryByTestId } = render(); + + await waitFor(() => expect(queryByTestId('mock-child')).not.toBeInTheDocument()); + await waitFor(() => { + const inProgress = queryByTestId('migration-in-progress'); + expect(inProgress).toBeInTheDocument(); + }); + await waitFor(() => expect(mockMigrations.applyMigrations).not.toHaveBeenCalled()); + }); + test('if password required, renders wallet locked screen and does not apply migrations', async () => { + mockMigrations.migrationsRequirePassword.mockResolvedValueOnce(true); + const { queryByTestId } = render(); + + await waitFor(() => expect(queryByTestId('mock-child')).not.toBeInTheDocument()); + await waitFor(() => { + const lock = queryByTestId('lock-screen'); + expect(lock).toBeInTheDocument(); + }); + await waitFor(() => expect(mockMigrations.migrationsRequirePassword).toHaveBeenCalled()); + await waitFor(() => expect(mockMigrations.applyMigrations).not.toHaveBeenCalled()); + }); + }); + }); + + describe('error', () => { + beforeEach(async () => { + await storage.local.set({ MIGRATION_STATE: { state: 'error', from: '1.0.0', to: '2.0.0' } }); + }); + + test('renders failed migration screen', async () => { + const { queryByTestId } = render(); + + await waitFor(() => expect(queryByTestId('mock-child')).not.toBeInTheDocument()); + await waitFor(() => { + const failed = queryByTestId('failed-migration'); + expect(failed).toBeInTheDocument(); + }); + expect(mockMigrations.applyMigrations).not.toHaveBeenCalled(); + }); + }); + + describe('not-applied', () => { + beforeEach(async () => { + await storage.local.set({ MIGRATION_STATE: { state: 'not-applied', from: '1.0.0', to: '2.0.0' } }); + }); + test('in popup mode, renders MainLoader screen with applying update message and applies migrations', async () => { + const { queryByTestId } = render(); + + await waitFor(() => expect(queryByTestId('mock-child')).not.toBeInTheDocument()); + await waitFor(() => { + const mainLoader = queryByTestId('main-loader'); + expect(mainLoader).toBeInTheDocument(); + expect(mainLoader).toHaveTextContent('Applying update...'); + }); + await waitFor(() => expect(mockMigrations.migrationsRequirePassword).toHaveBeenCalled()); + await waitFor(() => expect(mockMigrations.applyMigrations).toHaveBeenCalled()); + }); + test('in browser mode, renders migration in progress screen and does not apply migrations', async () => { + const { queryByTestId } = render(); + + await waitFor(() => expect(queryByTestId('mock-child')).not.toBeInTheDocument()); + await waitFor(() => { + const inProgress = queryByTestId('migration-in-progress'); + expect(inProgress).toBeInTheDocument(); + }); + await waitFor(() => expect(mockMigrations.applyMigrations).not.toHaveBeenCalled()); + }); + }); + }); +}); diff --git a/apps/browser-extension-wallet/src/components/MigrationContainer/index.ts b/apps/browser-extension-wallet/src/components/MigrationContainer/index.ts new file mode 100644 index 0000000000..9706349677 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/MigrationContainer/index.ts @@ -0,0 +1 @@ +export * from './MigrationContainer'; diff --git a/apps/browser-extension-wallet/src/components/NetworkPill/NetworkPill.module.scss b/apps/browser-extension-wallet/src/components/NetworkPill/NetworkPill.module.scss new file mode 100644 index 0000000000..c8179b7e70 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/NetworkPill/NetworkPill.module.scss @@ -0,0 +1,37 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../packages/common/src/ui/styles/abstracts/mixins'; + +.pill { + margin-left: size_unit(1.5); + border-radius: size_unit(2); + padding: size_unit(1) size_unit(1.5); +} + +.networkPill { + @extend .pill; + background-color: var(--dark-mode-dark-grey-plus, var(--color-magnolia, #fcf5e3)); + color: var(--text-color-primary); + @include text-bodyXS-medium; +} + +.offlinePill { + @extend .pill; + border: 1px solid var(--color-pink, #ff5470); + color: var(--color-pink, #ff5470); + font-size: size_unit(1.5); + font-weight: 400; + line-height: size_unit(1.75); +} + +.offlinePillText { + display: flex; + align-items: center; + justify-content: center; + gap: size_unit(0.5); + white-space: nowrap; + + .dot { + font-size: size_unit(3.75); + transform: translate(0, -18%); + } +} diff --git a/apps/browser-extension-wallet/src/components/NetworkPill/NetworkPill.tsx b/apps/browser-extension-wallet/src/components/NetworkPill/NetworkPill.tsx new file mode 100644 index 0000000000..242c1d4217 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/NetworkPill/NetworkPill.tsx @@ -0,0 +1,47 @@ +import React, { ReactElement, useMemo } from 'react'; +import styles from './NetworkPill.module.scss'; +import { useWalletStore } from '@src/stores'; +import { useNetwork } from '@hooks'; +import { Tooltip } from 'antd'; +import { useTranslation } from 'react-i18next'; + +export const NetworkPill = (): ReactElement => { + const { environmentName } = useWalletStore(); + const { t } = useTranslation(); + const { isOnline, isBackendFailing } = useNetwork(); + + return useMemo(() => { + if (isOnline && !isBackendFailing && environmentName !== 'Mainnet') { + return ( +
+ {environmentName} +
+ ); + } + if (!isOnline) { + return ( + +
+
+
+
{t('general.networks.offline')}
+
+
+
+ ); + } + if (isBackendFailing) { + return ( + +
+
+
+
{t('general.networks.connectionUnavailable.title')}
+
+
+
+ ); + } + return <>; + }, [isOnline, isBackendFailing, environmentName, t]); +}; diff --git a/apps/browser-extension-wallet/src/components/NetworkPill/index.ts b/apps/browser-extension-wallet/src/components/NetworkPill/index.ts new file mode 100644 index 0000000000..7f697bf0c5 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/NetworkPill/index.ts @@ -0,0 +1 @@ +export * from './NetworkPill'; diff --git a/apps/browser-extension-wallet/src/components/RecoveryPhrase/RecoveryPhrase.tsx b/apps/browser-extension-wallet/src/components/RecoveryPhrase/RecoveryPhrase.tsx new file mode 100644 index 0000000000..7393085aeb --- /dev/null +++ b/apps/browser-extension-wallet/src/components/RecoveryPhrase/RecoveryPhrase.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { BackButton, BackButtonProps } from '../BackButton'; + +export interface RecoveryPhraseProps { + handleBack: BackButtonProps['onBackClick']; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + recoveryPhrase: any; +} + +export const RecoveryPhrase = ({ handleBack }: RecoveryPhraseProps): React.ReactElement => ( +
+ + {/* TODO: add wallet recovery phrase verification, jira ticket need to be added, mock ups as well */} +
+); diff --git a/apps/browser-extension-wallet/src/components/RecoveryPhrase/__tests__/RecoveryPhrase.test.tsx b/apps/browser-extension-wallet/src/components/RecoveryPhrase/__tests__/RecoveryPhrase.test.tsx new file mode 100644 index 0000000000..15f81d5fde --- /dev/null +++ b/apps/browser-extension-wallet/src/components/RecoveryPhrase/__tests__/RecoveryPhrase.test.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { render, within, fireEvent } from '@testing-library/react'; +import { RecoveryPhrase, RecoveryPhraseProps } from '../RecoveryPhrase'; +import '@testing-library/jest-dom'; + +describe('Testing RecoveryPhrase component', () => { + const props = { + handleBack: jest.fn(), + recoveryPhrase: { + validateMnemonic: jest.fn(), + confirmMnemonic: jest.fn(), + mnemonicLength: 4 + } + } as RecoveryPhraseProps; + + // TODO: fix test once the RecoveryPhrase component is fully implemented + + test.skip('should render mnemonic inputs and label', async () => { + const { findByTestId } = render(); + + const container = await findByTestId('recovery-phrase'); + const inputs = await within(container).findAllByTestId(/mnemonic-input/i); + + // eslint-disable-next-line no-magic-numbers + expect(inputs).toHaveLength(8); + }); + + test.skip('should call event when click on back button ', async () => { + const { findByTestId } = render(); + + const backBtn = await findByTestId('recovery-phrase-back-btn'); + fireEvent.click(backBtn); + + expect(props.handleBack).toHaveBeenCalled(); + }); +}); diff --git a/apps/browser-extension-wallet/src/components/RecoveryPhrase/index.tsx b/apps/browser-extension-wallet/src/components/RecoveryPhrase/index.tsx new file mode 100644 index 0000000000..b7c5dc3f34 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/RecoveryPhrase/index.tsx @@ -0,0 +1 @@ +export * from './RecoveryPhrase'; diff --git a/apps/browser-extension-wallet/src/components/ResultMessage/ResultMessage.module.scss b/apps/browser-extension-wallet/src/components/ResultMessage/ResultMessage.module.scss new file mode 100644 index 0000000000..4229e3e9a7 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/ResultMessage/ResultMessage.module.scss @@ -0,0 +1,60 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../packages/common/src/ui/styles/abstracts/typography'; + +.content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: size_unit(4); + + .title { + color: var(--text-color-primary); + font-size: var(--heading); + font-weight: 700; + line-height: size_unit(5); + letter-spacing: -0.015em; + text-align: center; + margin-bottom: 0 !important; + + max-width: 435px; + + @media (max-width: $breakpoint-popup) { + font-size: var(--heading); + line-height: size_unit(4); + margin-bottom: 0; + } + } + + .description { + color: var(--text-color-secondary) !important; + font-size: var(--body); + font-weight: 500; + line-height: size_unit(3); + text-align: center; + margin-bottom: 0 !important; + max-width: 450px; + padding: 10px; + + @media (max-width: $breakpoint-popup) { + max-width: 260px; + padding: 0; + font-size: var(--body); + line-height: size_unit(3); + margin-bottom: 0; + } + } + .vertical { + display: flex; + flex-direction: column; + align-items: center; + gap: size_unit(2); + } + .img { + height: size_unit(10.5); + width: size_unit(10.5); + @media (max-width: $breakpoint-popup) { + width: size_unit(11); + } + } +} diff --git a/apps/browser-extension-wallet/src/components/ResultMessage/ResultMessage.tsx b/apps/browser-extension-wallet/src/components/ResultMessage/ResultMessage.tsx new file mode 100644 index 0000000000..b1352a767f --- /dev/null +++ b/apps/browser-extension-wallet/src/components/ResultMessage/ResultMessage.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import errorImg from '../../assets/icons/exclamation-circle.svg'; +import successImg from '../../assets/icons/clock-icon.svg'; +import styles from './ResultMessage.module.scss'; + +type Status = 'success' | 'error'; + +const bgImg: Record = { + success: successImg, + error: errorImg +}; + +export interface ResultMessageProps { + status?: Status; + title?: React.ReactNode; + description?: React.ReactNode; + customBgImg?: string; +} + +export const ResultMessage = ({ + status = 'success', + title, + description, + customBgImg +}: ResultMessageProps): React.ReactElement => ( +
+ result illustration +
+

+ {title} +

+
+ {description} +
+
+
+); diff --git a/apps/browser-extension-wallet/src/components/ResultMessage/index.ts b/apps/browser-extension-wallet/src/components/ResultMessage/index.ts new file mode 100644 index 0000000000..518640477e --- /dev/null +++ b/apps/browser-extension-wallet/src/components/ResultMessage/index.ts @@ -0,0 +1 @@ +export * from './ResultMessage'; diff --git a/apps/browser-extension-wallet/src/components/TransactionHashBox/TransactionHashBox.module.scss b/apps/browser-extension-wallet/src/components/TransactionHashBox/TransactionHashBox.module.scss new file mode 100644 index 0000000000..4fb2a321b9 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/TransactionHashBox/TransactionHashBox.module.scss @@ -0,0 +1,53 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; + +.container { + cursor: pointer; + height: 106px; + width: 301px; + border-radius: size_unit(2); + padding: size_unit(2); + background-color: transparent; + + div.hash { + font-size: var(--bodySmall); + font-weight: 500; + line-height: 17px; + text-align: center; + color: var(--text-color-secondary, #212121); + } + + .copyContainer { + margin-top: size_unit(1); + display: flex; + justify-content: center; + align-items: center; + gap: 6px; + + span.copy { + font-size: var(--bodySmall); + font-weight: 500; + line-height: size_unit(3); + text-align: center; + color: var(--text-color-secondary, #878e9e); + @media (max-width: $breakpoint-popup) { + font-size: var(--bodyXS); + line-height: size_unit(3); + color: var(--text-color-secondary, #878e9e); + } + } + } + + .copyIcon { + font-size: var(--body); + color: var(--text-color-secondary, #878e9e); + } + + .checkIcon { + font-size: var(--bodyLarge); + color: var(--data-green, #878e9e); + } + + &:hover { + background-color: var(--light-mode-light-grey, var(--dark-mode-mid-grey, #333333)); + } +} diff --git a/apps/browser-extension-wallet/src/components/TransactionHashBox/TransactionHashBox.tsx b/apps/browser-extension-wallet/src/components/TransactionHashBox/TransactionHashBox.tsx new file mode 100644 index 0000000000..edb0a5e21f --- /dev/null +++ b/apps/browser-extension-wallet/src/components/TransactionHashBox/TransactionHashBox.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Typography } from 'antd'; +import { CopyToClipboard } from 'react-copy-to-clipboard'; +import Copy from '../../assets/icons/copy.component.svg'; +import Check from '../../assets/icons/check-success.component.svg'; +import styles from './TransactionHashBox.module.scss'; +import { useAnalyticsContext } from '@providers'; +import { + AnalyticsEventActions, + AnalyticsEventCategories, + AnalyticsEventNames +} from '@providers/AnalyticsProvider/analyticsTracker'; + +export interface TransactionHashBoxProps { + hash: string; +} + +const { Text, Paragraph } = Typography; + +export const TransactionHashBox = ({ hash }: TransactionHashBoxProps): React.ReactElement => { + const { t } = useTranslation(); + const analytics = useAnalyticsContext(); + const [hasMouseEnter, setHasMouseEnter] = useState(false); + const [hasBeenCopied, setHasBeenCopied] = useState(false); + + const handleMouseEnter = () => { + setHasMouseEnter(true); + }; + const hadnelMouseLeave = () => { + setHasMouseEnter(false); + setHasBeenCopied(false); + }; + + const handleCopy = (_text: string, result: boolean) => { + analytics.sendEvent({ + action: AnalyticsEventActions.CLICK_EVENT, + category: AnalyticsEventCategories.SEND_TRANSACTION, + name: AnalyticsEventNames.SendTransaction.COPY_TX_HASH + }); + setHasBeenCopied(result); + }; + + const copyText = hasBeenCopied ? 'general.button.copied' : 'general.button.copy'; + + return ( + +
+ + {hash} + + {hasMouseEnter && ( +
+ {hasBeenCopied ? : } + + {t(copyText)}{' '} + +
+ )} +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/TransactionHashBox/index.ts b/apps/browser-extension-wallet/src/components/TransactionHashBox/index.ts new file mode 100644 index 0000000000..be0d119971 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/TransactionHashBox/index.ts @@ -0,0 +1 @@ +export * from './TransactionHashBox'; diff --git a/apps/browser-extension-wallet/src/components/TransitionAcknowledgmentDialog/TransitionAcknowledgmentDialog.module.scss b/apps/browser-extension-wallet/src/components/TransitionAcknowledgmentDialog/TransitionAcknowledgmentDialog.module.scss new file mode 100644 index 0000000000..3c2f967a5b --- /dev/null +++ b/apps/browser-extension-wallet/src/components/TransitionAcknowledgmentDialog/TransitionAcknowledgmentDialog.module.scss @@ -0,0 +1,34 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../styles/rules/modal.scss'; + +.transitionAcknowledgment { + @extend %modal-globals; +} + +.title { + text-align: center !important; + color: var(--text-color-primary) !important; +} + +.description { + color: var(--text-color-secondary) !important; + margin-top: size_unit(2) !important; + text-align: center; + font-size: size_unit(2) !important; + font-weight: 500 !important; +} + +.buttons { + display: flex; + gap: size_unit(3); + margin-top: size_unit(3) !important; + flex-direction: column; + width: size_unit(27); + justify-content: center; + align-items: center; +} + +.checkboxLabel { + color: var(--text-color-secondary) !important; + font-size: size_unit(2) !important; +} diff --git a/apps/browser-extension-wallet/src/components/TransitionAcknowledgmentDialog/TransitionAcknowledgmentDialog.tsx b/apps/browser-extension-wallet/src/components/TransitionAcknowledgmentDialog/TransitionAcknowledgmentDialog.tsx new file mode 100644 index 0000000000..26ea5d8b21 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/TransitionAcknowledgmentDialog/TransitionAcknowledgmentDialog.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import styles from './TransitionAcknowledgmentDialog.module.scss'; +import { Button } from '@lace/common'; +import { Checkbox, Modal, Typography } from 'antd'; +import { HW_POPUPS_WIDTH } from '@src/utils/constants'; +import { CheckboxChangeEvent } from 'antd/lib/checkbox'; +import { useTranslation } from 'react-i18next'; + +const { Title, Text } = Typography; + +interface TransitionAcknowledgmentDialogProps { + visible: boolean; + onClose: () => void; + title: string; + description: string; + confirmationLabel: string; + storageMemoEntryName: string; +} + +export const TransitionAcknowledgmentDialog = ({ + visible, + onClose, + title, + description, + confirmationLabel, + storageMemoEntryName +}: TransitionAcknowledgmentDialogProps): React.ReactElement => { + const { t } = useTranslation(); + const [checked, setChecked] = useState(false); + const onCheckboxChange = (e: CheckboxChangeEvent) => { + const newState = e.target.checked; + setChecked(newState); + localStorage.setItem(storageMemoEntryName, String(newState)); + }; + + return ( + + + {title} + + {description} +
+ + + + {t('browserView.onboarding.sendTransitionAcknowledgment.dontShowAgain')} + + +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/components/TransitionAcknowledgmentDialog/index.ts b/apps/browser-extension-wallet/src/components/TransitionAcknowledgmentDialog/index.ts new file mode 100644 index 0000000000..8ef5e34cb6 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/TransitionAcknowledgmentDialog/index.ts @@ -0,0 +1 @@ +export { TransitionAcknowledgmentDialog } from './TransitionAcknowledgmentDialog'; diff --git a/apps/browser-extension-wallet/src/components/WalletStatus/WalletStatus.module.scss b/apps/browser-extension-wallet/src/components/WalletStatus/WalletStatus.module.scss new file mode 100644 index 0000000000..3341420391 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/WalletStatus/WalletStatus.module.scss @@ -0,0 +1,39 @@ +@import '../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../packages/common/src/ui/styles/abstracts/typography'; + +.status { + display: flex; + justify-content: flex-start; + align-items: center; + gap: size_unit(1); + padding: size_unit(1) size_unit(1.5); + height: size_unit(4); + border: 1.5px solid var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey, #efefef)); + border-radius: size_unit(2); + + .statusDescription { + @include text-form-label($weight: 500); + letter-spacing: -0.015em; + text-align: left; + margin: 0; + color: var(--light-mode-dark-grey, var(--dark-mode-light-grey, #a9a9a9)); + } + + .statusCircle { + width: size_unit(1); + height: size_unit(1); + border-radius: 100px; + background-color: var(--light-mode-dark-grey, #878e9e); + + &.synced { + background-color: var(--data-green, #2cb67d); + } + &.notSynced { + background-color: var(--color-pink, #ff5470); + } + + &.syncing { + background-color: var(--color-yellow, #fdc300); + } + } +} diff --git a/apps/browser-extension-wallet/src/components/WalletStatus/WalletStatus.tsx b/apps/browser-extension-wallet/src/components/WalletStatus/WalletStatus.tsx new file mode 100644 index 0000000000..61d5ad446a --- /dev/null +++ b/apps/browser-extension-wallet/src/components/WalletStatus/WalletStatus.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import classnames from 'classnames'; +import styles from './WalletStatus.module.scss'; + +export enum Status { + SYNCING = 'syncing', + NOT_SYNCED = 'not synced', + SYNCED = 'synced' +} + +export interface WalletStatusProps { + text: string; + status?: Status; +} + +export const WalletStatus = ({ status, text }: WalletStatusProps): React.ReactElement => ( +
+
+

+ {text} +

+
+); diff --git a/apps/browser-extension-wallet/src/components/WalletStatus/WalletStatusContainer.tsx b/apps/browser-extension-wallet/src/components/WalletStatus/WalletStatusContainer.tsx new file mode 100644 index 0000000000..af383dcf7e --- /dev/null +++ b/apps/browser-extension-wallet/src/components/WalletStatus/WalletStatusContainer.tsx @@ -0,0 +1,27 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSyncStatus } from '@src/stores'; +import { WalletStatus, Status } from './WalletStatus'; + +const DEFAULT_WALLET_STATUS = { + status: Status.SYNCING, + text: 'browserView.topNavigationBar.walletStatus.walletSyncing' +}; + +export const WalletStatusContainer = (): React.ReactElement => { + const { t } = useTranslation(); + const [syncStatus, setSyncStatus] = useState(DEFAULT_WALLET_STATUS); + const status$ = useSyncStatus(); + + useEffect(() => { + const subscription = status$.subscribe((res: typeof DEFAULT_WALLET_STATUS) => { + setSyncStatus(res); + }); + + return () => { + if (subscription) subscription.unsubscribe(); + }; + }, [status$]); + + return ; +}; diff --git a/apps/browser-extension-wallet/src/components/WalletStatus/index.ts b/apps/browser-extension-wallet/src/components/WalletStatus/index.ts new file mode 100644 index 0000000000..f55beeffb9 --- /dev/null +++ b/apps/browser-extension-wallet/src/components/WalletStatus/index.ts @@ -0,0 +1,2 @@ +export * from './WalletStatus'; +export * from './WalletStatusContainer'; diff --git a/apps/browser-extension-wallet/src/config.ts b/apps/browser-extension-wallet/src/config.ts new file mode 100644 index 0000000000..50c41c5be7 --- /dev/null +++ b/apps/browser-extension-wallet/src/config.ts @@ -0,0 +1,89 @@ +/* eslint-disable no-magic-numbers */ +import { Wallet } from '@lace/cardano'; +import { EnvironmentTypes } from '@stores'; + +type CardanoServiceUrls = { + Mainnet: string; + Preprod: string; + Preview: string; +}; + +type Config = { + TOAST_DURATION: number; + CHAIN: Wallet.ChainName; + MNEMONIC_LENGTH: number; + WALLET_SYNC_TIMEOUT: number; + WALLET_INTERVAL: number; + CARDANO_SERVICES_URLS: CardanoServiceUrls; + ADA_PRICE_CHECK_INTERVAL: number; + AVAILABLE_CHAINS: Wallet.ChainName[]; + CEXPLORER_BASE_URL: Record; + SAVED_PRICE_DURATION: number; +}; + +// eslint-disable-next-line complexity +const envChecks = (chosenChain: Wallet.ChainName): void => { + if ( + !process.env.CARDANO_SERVICES_URL_MAINNET || + !process.env.CARDANO_SERVICES_URL_PREPROD || + !process.env.CARDANO_SERVICES_URL_PREVIEW + ) { + throw new Error('env vars not complete'); + } + + if ( + !process.env.CEXPLORER_URL_MAINNET || + !process.env.CEXPLORER_URL_PREVIEW || + !process.env.CEXPLORER_URL_PREPROD || + !process.env.CEXPLORER_URL_TESTNET + ) { + throw new Error('explorer vars not complete'); + } + + if (!process.env.AVAILABLE_CHAINS) { + throw new Error('no available chains to connect to'); + } + + if (!process.env.AVAILABLE_CHAINS.includes('Mainnet')) { + throw new Error('mainnet chain not available in env'); + } + + if (!Wallet.Cardano.ChainIds[chosenChain] || !process.env.AVAILABLE_CHAINS.includes(chosenChain)) { + throw new Error(`no chain available for selection: ${chosenChain}`); + } +}; + +export const config = (): Config => { + const chosenChain = (process.env.DEFAULT_CHAIN || 'Mainnet') as Wallet.ChainName; + envChecks(chosenChain); + return { + TOAST_DURATION: 1.5, + // TODO: review default chain for dev vs production building + CHAIN: chosenChain, + AVAILABLE_CHAINS: process.env.AVAILABLE_CHAINS.split(',') as Wallet.ChainName[], + MNEMONIC_LENGTH: 24, + WALLET_SYNC_TIMEOUT: !Number.isNaN(Number(process.env.WALLET_SYNC_TIMEOUT_IN_SEC)) + ? Number(process.env.WALLET_SYNC_TIMEOUT_IN_SEC) * 1000 + : 60 * 1000, + WALLET_INTERVAL: !Number.isNaN(Number(process.env.WALLET_INTERVAL_IN_SEC)) + ? Number(process.env.WALLET_INTERVAL_IN_SEC) * 1000 + : 30 * 1000, + ADA_PRICE_CHECK_INTERVAL: !Number.isNaN(Number(process.env.ADA_PRICE_POLLING_IN_SEC)) + ? Number(process.env.ADA_PRICE_POLLING_IN_SEC) * 1000 + : 30 * 1000, + CARDANO_SERVICES_URLS: { + Mainnet: process.env.CARDANO_SERVICES_URL_MAINNET, + Preprod: process.env.CARDANO_SERVICES_URL_PREPROD, + Preview: process.env.CARDANO_SERVICES_URL_PREVIEW + }, + CEXPLORER_BASE_URL: { + Mainnet: `${process.env.CEXPLORER_URL_MAINNET}/tx`, + LegacyTestnet: `${process.env.CEXPLORER_URL_TESTNET}/tx`, + Preprod: `${process.env.CEXPLORER_URL_PREPROD}/tx`, + Preview: `${process.env.CEXPLORER_URL_PREVIEW}/tx` + }, + SAVED_PRICE_DURATION: !Number.isNaN(Number(process.env.SAVED_PRICE_DURATION_IN_MINUTES)) + ? Number(process.env.SAVED_PRICE_DURATION_IN_MINUTES) + : 720 + }; +}; diff --git a/apps/browser-extension-wallet/src/dapp-connector.tsx b/apps/browser-extension-wallet/src/dapp-connector.tsx new file mode 100644 index 0000000000..87d90f4a73 --- /dev/null +++ b/apps/browser-extension-wallet/src/dapp-connector.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { render } from 'react-dom'; +import { DappConnectorView } from '@routes'; +import { StoreProvider } from '@stores'; +import '@lib/i18n'; +import 'antd/dist/antd.css'; +import { CurrencyStoreProvider } from '@providers/currency'; +import { DatabaseProvider, AxiosClientProvider, AppSettingsProvider, CardanoWalletManagerProvider } from '@providers'; +import { HashRouter } from 'react-router-dom'; +import { ThemeProvider } from '@providers/ThemeProvider'; +import { BackgroundServiceAPIProvider } from '@providers/BackgroundServiceAPI'; +import { APP_MODE_POPUP } from './utils/constants'; + +const App = (): React.ReactElement => ( + + + + + + + + + + + + + + + + + + + +); + +const mountNode = document.querySelector('#lace-popup'); +render(, mountNode); diff --git a/apps/browser-extension-wallet/src/features/activity/components/Activity.module.scss b/apps/browser-extension-wallet/src/features/activity/components/Activity.module.scss new file mode 100644 index 0000000000..9c1af254f2 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/activity/components/Activity.module.scss @@ -0,0 +1,11 @@ +@import '../../../styles/utils/functions.scss'; + +.activitiesContainer { + :global(.infinite-scroll-component) { + padding: 0 size_unit(1); + } +} +.emptyState { + padding-bottom: size_unit(3.5); + padding-top: size_unit(1); +} diff --git a/apps/browser-extension-wallet/src/features/activity/components/Activity.tsx b/apps/browser-extension-wallet/src/features/activity/components/Activity.tsx new file mode 100644 index 0000000000..419e05ff98 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/activity/components/Activity.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { ContentLayout } from '@src/components/Layout'; +import { useTranslation } from 'react-i18next'; +import { StateStatus, useWalletStore } from '@src/stores'; +import { useFetchCoinPrice, useRedirection } from '@hooks'; +import { useCurrencyStore } from '@providers/currency'; +import { Drawer, DrawerNavigation } from '@lace/common'; +import { GroupedAssetActivityList } from '@lace/core'; +import { TransactionDetail } from '@src/views/browser-view/features/activity'; +import styles from './Activity.module.scss'; +import { FundWalletBanner } from '@src/views/browser-view/components'; +import { walletRoutePaths } from '@routes'; +import { FetchWalletActivitiesReturn } from '@src/stores/slices'; +import { + AnalyticsEventActions, + AnalyticsEventCategories, + AnalyticsEventNames +} from '@providers/AnalyticsProvider/analyticsTracker'; +import { useAnalyticsContext } from '@providers'; + +export const Activity = (): React.ReactElement => { + const { t } = useTranslation(); + const [walletActivitiesObservable, setWalletActivitiesObservable] = useState(); + const { priceResult } = useFetchCoinPrice(); + const { + walletInfo, + walletActivities, + getWalletActivitiesObservable, + transactionDetail, + resetTransactionState, + activitiesCount, + walletActivitiesStatus + } = useWalletStore(); + const cardanoFiatPrice = priceResult?.cardano?.price; + const { fiatCurrency } = useCurrencyStore(); + const isLoading = walletActivitiesStatus !== StateStatus.LOADED; + const layoutTitle = `${t('browserView.activity.title')}`; + const layoutSideText = `(${activitiesCount})`; + const [redirectToAssets] = useRedirection(walletRoutePaths.assets); + const analytics = useAnalyticsContext(); + + const sendAnalytics = useCallback(() => { + analytics.sendEvent({ + category: AnalyticsEventCategories.VIEW_TRANSACTIONS, + action: AnalyticsEventActions.CLICK_EVENT, + name: AnalyticsEventNames.ViewTransactions.VIEW_TX_DETAILS_POPUP + }); + }, [analytics]); + + const fetchWalletActivities = useCallback(async () => { + const result = + fiatCurrency && + (await getWalletActivitiesObservable({ + fiatCurrency, + cardanoFiatPrice, + sendAnalytics + })); + setWalletActivitiesObservable(result); + }, [fiatCurrency, cardanoFiatPrice, getWalletActivitiesObservable, sendAnalytics]); + + useEffect(() => { + fetchWalletActivities(); + }, [fetchWalletActivities]); + + const hasActivities = walletActivities?.length > 0; + + useEffect(() => { + const subscription = walletActivitiesObservable?.subscribe(); + return () => { + if (subscription) subscription.unsubscribe(); + }; + }, [walletActivitiesObservable]); + + return ( + + { + resetTransactionState(); + redirectToAssets(); + }} + /> + } + popupView + > + {transactionDetail && priceResult && } + +
+ {hasActivities ? ( + + ) : ( +
+ +
+ )} +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/features/activity/components/index.ts b/apps/browser-extension-wallet/src/features/activity/components/index.ts new file mode 100644 index 0000000000..bf2c2bdaab --- /dev/null +++ b/apps/browser-extension-wallet/src/features/activity/components/index.ts @@ -0,0 +1 @@ +export * from './Activity'; diff --git a/apps/browser-extension-wallet/src/features/ada-handle/config.ts b/apps/browser-extension-wallet/src/features/ada-handle/config.ts new file mode 100644 index 0000000000..81f4343c76 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/ada-handle/config.ts @@ -0,0 +1,7 @@ +import { Cardano } from '@cardano-sdk/core'; + +export const HANDLE_SERVER_URLS: Record, string> = { + [Cardano.NetworkMagics.Mainnet]: 'https://api.handle.me', + [Cardano.NetworkMagics.Preprod]: 'https://preprod.api.handle.me', + [Cardano.NetworkMagics.Preview]: 'https://preview.api.handle.me' +}; diff --git a/apps/browser-extension-wallet/src/features/ada-handle/provider.test.ts b/apps/browser-extension-wallet/src/features/ada-handle/provider.test.ts new file mode 100644 index 0000000000..76642017c9 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/ada-handle/provider.test.ts @@ -0,0 +1,85 @@ +/* eslint-disable no-magic-numbers */ +/* eslint-disable camelcase */ +import { KoraLabsHandleProvider } from './provider'; +import { Wallet } from '@lace/cardano'; +import { createGenericMockServer } from '@cardano-sdk/util-dev'; + +const bobHandle = { + name: 'bob', + hasDatum: false, + resolved_addresses: { + ada: 'addr_test1qzrljm7nskakjydxlr450ktsj08zuw6aktvgfkmmyw9semrkrezryq3ydtmkg0e7e2jvzg443h0ffzfwd09wpcxy2fuql9tk0g' + } +}; + +const aliceHandle = { + name: 'alice', + hasDatum: false, + resolved_addresses: { + ada: 'addr_test1qqk4sr4f7vtqzd2w90d5nfu3n59jhhpawyphnek2y7er02nkrezryq3ydtmkg0e7e2jvzg443h0ffzfwd09wpcxy2fuqmcnecd' + } +}; + +export const mockServer = createGenericMockServer((handler) => async (req, res) => { + const result = await handler(req); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.statusCode = result.code || 200; + return res.end(JSON.stringify(result.body)); +}); + +describe('HandleProvider', () => { + it('should resolve a handle', async () => { + const { serverUrl, closeMock } = await mockServer(async () => ({ + body: bobHandle + })); + const provider = new KoraLabsHandleProvider({ + networkInfoProvider: Wallet.mockUtils.networkInfoProviderStub(), + serverUrl + }); + const tip = await Wallet.mockUtils.networkInfoProviderStub().ledgerTip(); + const result = await provider.resolveHandles({ handles: ['bob'] }); + expect(result[0].handle).toEqual('bob'); + expect(result[0].resolvedAddresses.cardano).toEqual(bobHandle.resolved_addresses.ada); + expect(result[0].hasDatum).toEqual(false); + expect(result[0].resolvedAt.hash).toEqual(tip.hash); + expect(result[0].resolvedAt.slot).toEqual(tip.slot); + await closeMock(); + }); + + it('should resolve multiple handles', async () => { + const { serverUrl, closeMock } = await mockServer(async (req) => + req.url === '/handles/bob' ? { body: bobHandle } : { body: aliceHandle } + ); + const provider = new KoraLabsHandleProvider({ + networkInfoProvider: Wallet.mockUtils.networkInfoProviderStub(), + serverUrl + }); + const result = await provider.resolveHandles({ handles: ['bob', 'alice'] }); + expect(result[0].handle).toEqual('bob'); + expect(result[1].handle).toEqual('alice'); + await closeMock(); + }); + + it('should get ok health check', async () => { + const { serverUrl, closeMock } = await mockServer(async () => ({ + body: { ok: true } + })); + const provider = new KoraLabsHandleProvider({ + networkInfoProvider: Wallet.mockUtils.networkInfoProviderStub(), + serverUrl + }); + const result = await provider.healthCheck(); + expect(result.ok).toEqual(true); + await closeMock(); + }); + + it('should get not ok health check', async () => { + const provider = new KoraLabsHandleProvider({ + networkInfoProvider: Wallet.mockUtils.networkInfoProviderStub(), + serverUrl: '' + }); + const result = await provider.healthCheck(); + expect(result.ok).toEqual(false); + }); +}); diff --git a/apps/browser-extension-wallet/src/features/ada-handle/provider.ts b/apps/browser-extension-wallet/src/features/ada-handle/provider.ts new file mode 100644 index 0000000000..6e5ae9ecc2 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/ada-handle/provider.ts @@ -0,0 +1,45 @@ +import axios, { AxiosInstance } from 'axios'; +import { NetworkInfoProvider, HealthCheckResponse, ProviderError, ProviderFailure } from '@cardano-sdk/core'; +import { IHandle } from '@koralabs/handles-public-api-interfaces'; +import { HandleInfo, HandleProvider, ResolveHandlesArgs } from './types'; +import { toHandleInfo } from './util'; + +export interface KoraLabsHandleProviderDeps { + serverUrl: string; + networkInfoProvider: NetworkInfoProvider; +} + +export class KoraLabsHandleProvider implements HandleProvider { + private axiosClient: AxiosInstance; + private networkInfoProvider: NetworkInfoProvider; + + constructor({ serverUrl, networkInfoProvider }: KoraLabsHandleProviderDeps) { + this.networkInfoProvider = networkInfoProvider; + this.axiosClient = axios.create({ + baseURL: serverUrl + }); + } + + async resolveHandles(args: ResolveHandlesArgs): Promise { + try { + const tip = await this.networkInfoProvider.ledgerTip(); + const response = await Promise.all( + args.handles.map((handle) => this.axiosClient.get(`/handles/${handle}`)) + ); + return response.map(({ data: apiResponse }) => toHandleInfo({ apiResponse, tip })); + } catch (error) { + if (axios.isAxiosError(error)) { + throw new ProviderError(ProviderFailure.Unhealthy, error, `Failed to resolve handles due to: ${error.message}`); + } + throw error; + } + } + async healthCheck(): Promise { + try { + await this.axiosClient.get('/health'); + return { ok: true }; + } catch { + return { ok: false }; + } + } +} diff --git a/apps/browser-extension-wallet/src/features/ada-handle/types.ts b/apps/browser-extension-wallet/src/features/ada-handle/types.ts new file mode 100644 index 0000000000..1a95cf8263 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/ada-handle/types.ts @@ -0,0 +1,27 @@ +import { Point, Provider } from '@cardano-sdk/core'; +import { Wallet } from '@lace/cardano'; + +type Handle = string; + +type CardanoAddress = + | Wallet.Cardano.ByronAddress + | Wallet.Cardano.PaymentAddress + | Wallet.Cardano.EnterpriseAddress + | Wallet.Cardano.PointerAddress; + +export interface HandleInfo { + handle: Handle; + hasDatum: boolean; + resolvedAddresses: { + cardano: CardanoAddress; + }; + resolvedAt: Point; +} + +export interface ResolveHandlesArgs { + handles: Handle[]; +} + +export interface HandleProvider extends Provider { + resolveHandles(args: ResolveHandlesArgs): Promise; +} diff --git a/apps/browser-extension-wallet/src/features/ada-handle/util.ts b/apps/browser-extension-wallet/src/features/ada-handle/util.ts new file mode 100644 index 0000000000..a7ce5f2f3f --- /dev/null +++ b/apps/browser-extension-wallet/src/features/ada-handle/util.ts @@ -0,0 +1,15 @@ +import { Cardano } from '@cardano-sdk/core'; +import { IHandle } from '@koralabs/handles-public-api-interfaces'; +import { HandleInfo } from './types'; + +export const toHandleInfo = ({ apiResponse, tip }: { apiResponse: IHandle; tip: Cardano.Tip }): HandleInfo => ({ + handle: apiResponse.name, + hasDatum: apiResponse.hasDatum, + resolvedAddresses: { + cardano: Cardano.PaymentAddress(apiResponse.resolved_addresses.ada) + }, + resolvedAt: { + hash: tip.hash, + slot: tip.slot + } +}); diff --git a/apps/browser-extension-wallet/src/features/address-book/components/AddressBook.modules.scss b/apps/browser-extension-wallet/src/features/address-book/components/AddressBook.modules.scss new file mode 100644 index 0000000000..dd6aa6fd07 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/components/AddressBook.modules.scss @@ -0,0 +1,58 @@ +@import '../../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../styles/index'; + +.listContainer { + height: 86%; + margin-top: 20px; + margin-left: -#{size_unit(1)}; + margin-right: -#{size_unit(1)}; + + @media (max-width: $breakpoint-popup) { + margin-top: size_unit(2); + } +} + +.emptyScreen { + height: 100%; + margin-top: size_unit(10); +} + +.addressList { + :global { + .ant-list-items { + list-style: none; + margin: 0px; + padding: 0px; + + p { + margin: 0; + text-align: right; + } + } + } +} + +.title { + color: var(--text-color-primary); + margin: 0 size_unit(3) size_unit(3); + .subTitle { + color: var(--text-color-secondary); + font-size: var(--bodyLarge); + font-weight: $font-weight-bold; + line-height: size_unit(5); + margin-left: size_unit(1); + } + h1 { + @include text-heading; + color: var(--text-color-primary); + align-items: baseline; + display: flex; + margin: 0; + } +} + +.btnContainer { + button { + font-weight: $font-weight-bold !important; + } +} diff --git a/apps/browser-extension-wallet/src/features/address-book/components/AddressBook.tsx b/apps/browser-extension-wallet/src/features/address-book/components/AddressBook.tsx new file mode 100644 index 0000000000..626a95e5ef --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/components/AddressBook.tsx @@ -0,0 +1,138 @@ +import React, { useMemo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { WalletAddressList, WalletAddressItemProps } from '@lace/core'; +import { Button } from '@lace/common'; +import { ContentLayout } from '@src/components/Layout'; +import { AddressBookSchema } from '@src/lib/storage'; +import { AddressBookEmpty } from '@src/views/browser-view/features/adress-book/components/AddressBookEmpty'; +import { withAddressBookContext, useAddressBookContext } from '../context'; +import { AddressDetailDrawer } from '../components/AddressDetailDrawer'; +import { useAddressBookStore } from '../store'; +import styles from './AddressBook.modules.scss'; +import DeleteIcon from '../../../assets/icons/delete-icon.component.svg'; +import AddIcon from '../../../assets/icons/add.component.svg'; +import PlusIcon from '../../../assets/icons/plus-icon.svg'; +import EditIcon from '../../../assets/icons/edit.component.svg'; +import { + AnalyticsEventActions, + AnalyticsEventCategories, + AnalyticsEventNames +} from '@providers/AnalyticsProvider/analyticsTracker'; +import { useAnalyticsContext } from '@providers'; + +const scrollableTargetId = 'popupAddressBookContainerId'; + +export const AddressBook = withAddressBookContext(() => { + const { list: addressList, count: addressCount, utils } = useAddressBookContext(); + const { saveRecord: saveAddress, updateRecord: updateAddress, extendLimit, deleteRecord: deleteAddress } = utils; + const { setIsEditAddressVisible, isEditAddressVisible, setAddressToEdit, addressToEdit } = useAddressBookStore(); + const { t: translate } = useTranslation(); + const analytics = useAnalyticsContext(); + + const addressListTranslations = { + name: translate('core.walletAddressList.name'), + address: translate('core.walletAddressList.address') + }; + + const onAddressSave = (address: AddressBookSchema | Omit): Promise => { + analytics.sendEvent({ + category: AnalyticsEventCategories.ADDRESS_BOOK, + action: AnalyticsEventActions.CLICK_EVENT, + name: AnalyticsEventNames.AddressBook.ADD_ADDRESS_POPUP + }); + return 'id' in addressToEdit + ? updateAddress(addressToEdit.id, address, { + text: translate('browserView.addressBook.toast.editAddress'), + icon: EditIcon + }) + : saveAddress(address, { + text: translate('browserView.addressBook.toast.addAddress'), + icon: AddIcon + }); + }; + + const list: WalletAddressItemProps[] = useMemo( + () => + addressList?.map((item: AddressBookSchema) => ({ + id: item.id, + address: item.address, + name: item.name, + onClick: (address: AddressBookSchema) => { + analytics.sendEvent({ + category: AnalyticsEventCategories.ADDRESS_BOOK, + action: AnalyticsEventActions.CLICK_EVENT, + name: AnalyticsEventNames.AddressBook.VIEW_ADDRESS_DETAILS_POPUP + }); + setAddressToEdit(address); + setIsEditAddressVisible(true); + }, + isSmall: true + })) || [], + [addressList, analytics, setAddressToEdit, setIsEditAddressVisible] + ); + + const loadMoreData = useCallback(() => { + extendLimit(); + }, [extendLimit]); + + return ( + <> + +

+ {translate('addressBook.sectionTitle')}{' '} + + ({addressCount}) + +

+
+ } + id={scrollableTargetId} + > +
+ +
+ + {addressCount === 0 ? ( +
+ +
+ ) : ( +
+ +
+ )} + + { + setAddressToEdit({} as AddressBookSchema); + setIsEditAddressVisible(false); + }} + onConfirmClick={onAddressSave} + onDelete={(id) => + deleteAddress(id, { + text: translate('browserView.addressBook.toast.deleteAddress'), + icon: DeleteIcon + }) + } + visible={isEditAddressVisible} + popupView + /> + + ); +}); diff --git a/apps/browser-extension-wallet/src/features/address-book/components/AddressDetailDrawer/AddressDetailDrawer.module.scss b/apps/browser-extension-wallet/src/features/address-book/components/AddressDetailDrawer/AddressDetailDrawer.module.scss new file mode 100644 index 0000000000..76b5805e2e --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/components/AddressDetailDrawer/AddressDetailDrawer.module.scss @@ -0,0 +1,77 @@ +@import '../../../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../styles/index'; + +.container { + display: flex; + flex-direction: column; + flex: 1; + justify-content: space-between; + margin-top: size_unit(3); +} + +.body { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1; + + .name { + color: var(--text-color-primary); + @include text-bodyLarge-bold; + } + + .extended { + text-align: center; + } + + .addressWrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: size_unit(3); + margin-top: size_unit(1); + .address { + @include text-body-semi-bold; + max-width: 360px; + color: var(--text-color-secondary); + word-break: break-all; + flex: 1; + margin-top: size_unit(2); + } + .extended { + text-align: center; + } + } +} + +.popupView { + .addressWrapper { + flex-direction: column; + } + .footer { + gap: size_unit(1); + } +} + +.footer { + display: flex; + flex-direction: column; + gap: size_unit(2); + + button { + font-weight: $font-weight-bold !important; + } +} + +.copyAddressButton { + width: 100%; + font-weight: $font-weight-bold !important; +} + +.copyIcon { + margin-right: size_unit(1); +} + +.deleteButton { + color: var(--text-color-red) !important; +} diff --git a/apps/browser-extension-wallet/src/features/address-book/components/AddressDetailDrawer/AddressDetailDrawer.tsx b/apps/browser-extension-wallet/src/features/address-book/components/AddressDetailDrawer/AddressDetailDrawer.tsx new file mode 100644 index 0000000000..4655116927 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/components/AddressDetailDrawer/AddressDetailDrawer.tsx @@ -0,0 +1,281 @@ +/* eslint-disable complexity */ +import React, { useEffect, useState } from 'react'; +import cn from 'classnames'; +import { useTranslation } from 'react-i18next'; +import CopyToClipboard from 'react-copy-to-clipboard'; +import { Button, toast, Drawer, DrawerHeader, DrawerNavigation } from '@lace/common'; +import { EditAddressForm, valuesPropType, ValidationOptionsProps, FormKeys } from '@lace/core'; +import { validateWalletName, validateWalletAddress } from '@src/utils/validators/address-book'; +import { AddressDetailsSteps, AddressDetailsConfig, AddressDetailsSectionConfig } from './types'; +import { DeleteAddressModal } from '../DeleteAddressModal'; +import styles from './AddressDetailDrawer.module.scss'; +import Copy from '@src/assets/icons/copy.component.svg'; +import Icon from '@ant-design/icons'; +import EditAddressFormFooter from '@src/features/address-book/components/AddressDetailDrawer/EditAddressFormFooter'; +import { useAnalyticsContext } from '@providers'; +import { + AnalyticsEventActions, + AnalyticsEventCategories, + AnalyticsEventNames +} from '@providers/AnalyticsProvider/analyticsTracker'; +import { useKeyboardShortcut } from '@hooks'; + +const config: AddressDetailsConfig = { + [AddressDetailsSteps.DETAIL]: { + currentSection: AddressDetailsSteps.DETAIL, + nextSection: AddressDetailsSteps.FORM + }, + [AddressDetailsSteps.FORM]: { + currentSection: AddressDetailsSteps.FORM, + prevSection: AddressDetailsSteps.DETAIL + } +}; + +type InitialValuesProps = { + address?: string; + id?: number; + name?: string; +}; + +const validations: ValidationOptionsProps = { + name: validateWalletName, + address: validateWalletAddress +}; + +export type AddressDetailDrawerProps = { + initialValues: InitialValuesProps; + onCancelClick: (event?: React.MouseEvent) => unknown; + onConfirmClick: (values: valuesPropType) => unknown; + onDelete: (address: InitialValuesProps['id']) => unknown; + visible: boolean; + popupView?: boolean; +}; + +export const AddressDetailDrawer = ({ + initialValues, + onCancelClick, + onConfirmClick, + visible, + onDelete, + popupView +}: AddressDetailDrawerProps): React.ReactElement => { + const { t } = useTranslation(); + const [selectedId, setSelectedId] = useState(); + const [stepsConfig, setStepsConfig] = useState( + config[initialValues?.id ? AddressDetailsSteps.DETAIL : AddressDetailsSteps.FORM] + ); + useEffect(() => { + setStepsConfig(config[initialValues?.id ? AddressDetailsSteps.DETAIL : AddressDetailsSteps.FORM]); + }, [initialValues?.id]); + const showArrowIcon = stepsConfig.currentSection === AddressDetailsSteps.FORM || popupView; + const [formValues, setFormValues] = useState(initialValues || {}); + const analytics = useAnalyticsContext(); + + const editAddressFormTranslations = { + walletName: t('core.addressForm.name'), + address: t('core.editAddressForm.address') + }; + + const onClose = () => { + if (!popupView) { + setStepsConfig(config[AddressDetailsSteps.DETAIL]); + onCancelClick(); + } else { + onCancelClick(); + } + }; + + const onArrowIconClick = () => + popupView && (!config[stepsConfig.prevSection] || !initialValues?.id) + ? onCancelClick() + : setStepsConfig(config[stepsConfig.prevSection]); + + useKeyboardShortcut(['Escape'], () => { + if (selectedId) { + // eslint-disable-next-line unicorn/no-null + setSelectedId(null); + return; + } + config[stepsConfig.prevSection] ? onArrowIconClick() : onClose(); + }); + + const getFieldError = (key: FormKeys) => validations[key]?.(formValues[key]); + + const handleOnCancelClick = () => { + if (!popupView || initialValues?.id) { + setStepsConfig(config[AddressDetailsSteps.DETAIL]); + } else { + onCancelClick(); + } + }; + + const sendAnalytics = (name: string) => { + analytics.sendEvent({ + category: AnalyticsEventCategories.ADDRESS_BOOK, + action: AnalyticsEventActions.CLICK_EVENT, + name + }); + }; + + return ( + <> + + } + navigation={ + + } + footer={ + <> + {stepsConfig.currentSection === AddressDetailsSteps.DETAIL && ( +
+ + +
+ )} + {stepsConfig.currentSection === AddressDetailsSteps.FORM && ( + + )} + + } + visible={visible} + popupView={popupView} + > + {visible && ( + <> + {stepsConfig.currentSection === AddressDetailsSteps.DETAIL && ( +
+
+
+ {initialValues.name} +
+
+
+ {initialValues.address} +
+ + + +
+
+
+ )} + {stepsConfig.currentSection === AddressDetailsSteps.FORM && ( +
+ +
+ )} + + )} +
+ { + sendAnalytics( + popupView + ? AnalyticsEventNames.AddressBook.CANCEL_DELETE_ADDRESS_POPUP + : AnalyticsEventNames.AddressBook.CANCEL_DELETE_ADDRESS_BROWSER + ); + // eslint-disable-next-line unicorn/no-null + setSelectedId(null); + }} + onConfirm={() => { + sendAnalytics( + popupView + ? AnalyticsEventNames.AddressBook.CONFIRM_DELETE_ADDRESS_POPUP + : AnalyticsEventNames.AddressBook.CONFIRM_DELETE_ADDRESS_BROWSER + ); + onDelete(selectedId); + // eslint-disable-next-line unicorn/no-null + setSelectedId(null); + onCancelClick(); + }} + visible={!!selectedId} + isSmall={popupView} + /> + + ); +}; diff --git a/apps/browser-extension-wallet/src/features/address-book/components/AddressDetailDrawer/EditAddressFormFooter.tsx b/apps/browser-extension-wallet/src/features/address-book/components/AddressDetailDrawer/EditAddressFormFooter.tsx new file mode 100644 index 0000000000..b1a4a94151 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/components/AddressDetailDrawer/EditAddressFormFooter.tsx @@ -0,0 +1,57 @@ +import React, { ReactElement } from 'react'; +import styles from '@src/features/address-book/components/AddressDetailDrawer/AddressDetailDrawer.module.scss'; +import { Button } from '@lace/common'; +import { FormKeys, ValidationOptionsProps, valuesPropType } from '@lace/core'; +import { useTranslation } from 'react-i18next'; + +interface EditAddressFormFooterProps { + validations: ValidationOptionsProps; + formValues: valuesPropType; + isNewAddress?: boolean; + onCancelClick: (event?: React.MouseEvent) => unknown; + onConfirmClick: (values: valuesPropType) => unknown; + onClose?: () => void; + getFieldError: (keys: FormKeys) => string; + currentName?: string; +} + +const EditAddressFormFooter = ({ + validations, + formValues, + isNewAddress, + onConfirmClick, + onCancelClick, + onClose, + getFieldError +}: EditAddressFormFooterProps): ReactElement => { + const { t } = useTranslation(); + + const isFormValid = () => { + const formKeys: Array = Object.keys(validations) as FormKeys[]; + return !formKeys.some((key) => !!getFieldError(key)); + }; + + const onSubmitAddress = async () => { + if (isFormValid()) { + try { + await onConfirmClick(formValues); + if (onClose) onClose(); + } catch { + // TODO: add nicer way to handle errors, console messega removed by QA request + } + } + }; + + return ( +
+ + +
+ ); +}; + +export default EditAddressFormFooter; diff --git a/apps/browser-extension-wallet/src/features/address-book/components/AddressDetailDrawer/index.ts b/apps/browser-extension-wallet/src/features/address-book/components/AddressDetailDrawer/index.ts new file mode 100644 index 0000000000..0650bc296a --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/components/AddressDetailDrawer/index.ts @@ -0,0 +1 @@ +export * from './AddressDetailDrawer'; diff --git a/apps/browser-extension-wallet/src/features/address-book/components/AddressDetailDrawer/types/index.ts b/apps/browser-extension-wallet/src/features/address-book/components/AddressDetailDrawer/types/index.ts new file mode 100644 index 0000000000..af4ce3e51c --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/components/AddressDetailDrawer/types/index.ts @@ -0,0 +1,12 @@ +export enum AddressDetailsSteps { + DETAIL = 'detail', + FORM = 'form' +} + +export interface AddressDetailsSectionConfig { + currentSection: AddressDetailsSteps; + nextSection?: AddressDetailsSteps; + prevSection?: AddressDetailsSteps; +} + +export type AddressDetailsConfig = Partial>; diff --git a/apps/browser-extension-wallet/src/features/address-book/components/DeleteAddressModal.module.scss b/apps/browser-extension-wallet/src/features/address-book/components/DeleteAddressModal.module.scss new file mode 100644 index 0000000000..d76f1bc544 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/components/DeleteAddressModal.module.scss @@ -0,0 +1,48 @@ +@import '../../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../styles/index'; + +.modal { + :global(.ant-modal-content) { + background: var(--color-white, var(--dark-mode-light-black)); + box-shadow: var(--shadows-card-pop-up) !important; + border-radius: size_unit(4) !important; + } + :global(.ant-modal-body) { + display: flex; + flex-direction: column; + padding: size_unit(5) !important; + } +} + +.header { + @include text-subHeading-bold; + color: var(--text-color-primary); + padding-bottom: size_unit(3); + text-align: center; +} + +.content { + @include text-body-semi-bold; + color: var(--text-color-secondary); + padding-bottom: size_unit(4); + text-align: center; +} + +.footer { + display: flex; + gap: size_unit(2); + justify-content: space-between; + .btn { + flex: 1; + } +} + +.isSmall { + .footer { + flex-direction: column-reverse; + .btn { + min-height: size_unit(6) !important; + width: 100%; + } + } +} diff --git a/apps/browser-extension-wallet/src/features/address-book/components/DeleteAddressModal.tsx b/apps/browser-extension-wallet/src/features/address-book/components/DeleteAddressModal.tsx new file mode 100644 index 0000000000..796eadccd6 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/components/DeleteAddressModal.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import cn from 'classnames'; +import { Modal, ModalProps } from 'antd'; +import { Button } from '@lace/common'; +import { useTranslation } from 'react-i18next'; +import styles from './DeleteAddressModal.module.scss'; + +const modalWidth = 479; + +export type DeleteAddressModalProps = { + onCancel: () => void; + onConfirm: () => void; + isSmall?: boolean; +} & ModalProps; + +export const DeleteAddressModal = ({ + onCancel, + onConfirm, + visible, + isSmall +}: DeleteAddressModalProps): React.ReactElement => { + const { t: translate } = useTranslation(); + return ( + +
+ {translate('browserView.addressBook.deleteModal.title')} +
+
+ {isSmall ? ( +
{translate('browserView.addressBook.deleteModal.description')}
+ ) : ( + <> +
{translate('browserView.addressBook.deleteModal.description1')}
+
{translate('browserView.addressBook.deleteModal.description2')}
+ + )} +
+
+ + +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/features/address-book/context/AddressBookProvider.tsx b/apps/browser-extension-wallet/src/features/address-book/context/AddressBookProvider.tsx new file mode 100644 index 0000000000..48f6ad5dea --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/context/AddressBookProvider.tsx @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable react/no-multi-comp */ +import React, { useMemo } from 'react'; +import { AddressBookContext } from './context'; +import { addressBookQueries, AddressBookSchema, addressBookSchema, useDbState } from '@src/lib/storage'; +import { useWalletStore } from '@src/stores'; +import { Wallet } from '@lace/cardano'; + +interface AddressBookProviderProps { + children: React.ReactNode; + initialState?: Array; +} + +export type AddressRecordParams = Pick; + +export const AddressBookProvider = ({ children, initialState }: AddressBookProviderProps): React.ReactElement => { + const { environmentName } = useWalletStore(); + const queries = useMemo( + () => + addressBookQueries( + environmentName === 'Mainnet' ? Wallet.Cardano.NetworkId.Mainnet : Wallet.Cardano.NetworkId.Testnet + ), + [environmentName] + ); + + return ( + (initialState, addressBookSchema, queries)} + > + {children} + + ); +}; + +type WithAddressBookContext = ( + element: React.JSXElementConstructor, + initialState?: Array +) => (props?: TProps) => React.ReactElement; + +export const withAddressBookContext: WithAddressBookContext = (Component, initialState) => (props) => + ( + + + + ); diff --git a/apps/browser-extension-wallet/src/features/address-book/context/__test__/context.test.tsx b/apps/browser-extension-wallet/src/features/address-book/context/__test__/context.test.tsx new file mode 100644 index 0000000000..40e06520d1 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/context/__test__/context.test.tsx @@ -0,0 +1,137 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-magic-numbers */ +import React, { FunctionComponent } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useAddressBookContext } from '../context'; +import { AddressBookProvider } from '../AddressBookProvider'; +import { WalletDatabase, AddressBookSchema, addressBookSchema, useDbStateValue } from '@src/lib/storage'; +import { DatabaseProvider } from '@src/providers/DatabaseProvider'; +import { StoreProvider } from '@src/stores'; +import create from 'zustand'; +import { AppSettingsProvider } from '@providers'; + +jest.mock('../AddressBookProvider', () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...jest.requireActual('../AddressBookProvider'), + withAddressBookContext: jest.fn() +})); + +const makeDbContextWrapper = + (dbIntance: WalletDatabase): FunctionComponent => + ({ children }: { children?: React.ReactNode }) => + ( + + ({ environmentName: 'Preprod' } as any))}> + + {children} + + + + ); + +describe('testing useAddressBookState', () => { + let db: WalletDatabase; + const mockAddressList: AddressBookSchema[] = Array.from({ length: 15 }, (_v, i) => ({ + id: i + 1, + address: `addr_test${i + 1}`, + name: `atest wallet ${i + 1}`, + network: 0 + })); + + beforeEach(async () => { + db = new WalletDatabase(); + db.getConnection(addressBookSchema).bulkAdd(mockAddressList); + }); + + afterEach(() => db.delete()); + + test('should return state and utils', async () => { + const { result, waitForNextUpdate } = renderHook(() => useAddressBookContext(), { + wrapper: makeDbContextWrapper(db) + }); + expect(result.current.utils.deleteRecord).toBeDefined(); + expect(result.current.utils.saveRecord).toBeDefined(); + expect(result.current.utils.extendLimit).toBeDefined(); + + await waitForNextUpdate(); + + expect(result.current.list.length).toBe(10); + expect(result.current.count).toBe(15); + }); + + test('should add new address and extend limit', async () => { + const { result, waitForNextUpdate } = renderHook(() => useAddressBookContext(), { + wrapper: makeDbContextWrapper(db) + }); + + await waitForNextUpdate(); + + result.current.utils.saveRecord({ address: 'addr_test16', name: 'test wallet 16' }); + + await waitForNextUpdate(); + + result.current.utils.extendLimit(); + + await waitForNextUpdate(); + + expect(result.current.list).toContainEqual({ + address: 'addr_test16', + id: 16, + name: 'test wallet 16', + network: 0 + }); + expect(result.current.list.length).toBe(16); + expect(result.current.count).toBe(16); + }); + + test('should update address', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useAddressBookContext() as useDbStateValue, + { + wrapper: makeDbContextWrapper(db) + } + ); + + await waitForNextUpdate(); + + const idToUpdate = result.current.list[0].id; + const addressData = { + id: result.current.list[0].id, + name: 'newName', + address: 'newAddress', + network: 0 + }; + + result.current.utils.updateRecord(idToUpdate, addressData as AddressBookSchema); + + await waitForNextUpdate(); + + expect(result.current.list).toContainEqual({ ...addressData, id: idToUpdate }); + expect(result.current.list.length).toBe(10); + expect(result.current.count).toBe(15); + }); + + test('should delete address', async () => { + const { result, waitForNextUpdate } = renderHook(() => useAddressBookContext(), { + wrapper: makeDbContextWrapper(db) + }); + + await waitForNextUpdate(); + + result.current.utils.deleteRecord(1); + + await waitForNextUpdate(); + + result.current.utils.extendLimit(); + + await waitForNextUpdate(); + + expect(result.current.list).not.toContainEqual({ + address: 'addr_test1', + name: 'test wallet', + id: 1 + }); + expect(result.current.list.length).toBe(14); + expect(result.current.count).toBe(14); + }); +}); diff --git a/apps/browser-extension-wallet/src/features/address-book/context/context.ts b/apps/browser-extension-wallet/src/features/address-book/context/context.ts new file mode 100644 index 0000000000..c1c31f81b9 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/context/context.ts @@ -0,0 +1,11 @@ +import { createContext, useContext } from 'react'; +import { AddressBookSchema, useDbState, useDbStateValue } from '../../../lib/storage'; + +// eslint-disable-next-line unicorn/no-null +export const AddressBookContext = createContext | null>(null); + +export const useAddressBookContext = (): ReturnType => { + const bookContext = useContext(AddressBookContext); + if (bookContext === null) throw new Error('AddressBookContext is not defined.'); + return bookContext; +}; diff --git a/apps/browser-extension-wallet/src/features/address-book/context/index.ts b/apps/browser-extension-wallet/src/features/address-book/context/index.ts new file mode 100644 index 0000000000..fb5cec7363 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/context/index.ts @@ -0,0 +1,2 @@ +export * from './context'; +export * from './AddressBookProvider'; diff --git a/apps/browser-extension-wallet/src/features/address-book/hooks/__tests__/useGetFilteredAddressBook.test.tsx b/apps/browser-extension-wallet/src/features/address-book/hooks/__tests__/useGetFilteredAddressBook.test.tsx new file mode 100644 index 0000000000..703ce07e73 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/hooks/__tests__/useGetFilteredAddressBook.test.tsx @@ -0,0 +1,102 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-magic-numbers */ +/* eslint-disable sonarjs/no-duplicate-string */ +import React, { FunctionComponent } from 'react'; +import { useGetFilteredAddressBook } from '../useGetFilteredAddressBook'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { DatabaseProvider } from '../../../../providers/DatabaseProvider'; +import { WalletDatabase, AddressBookSchema, addressBookSchema } from '../../../../lib/storage'; +import { StoreProvider } from '@src/stores'; +import create from 'zustand'; +import { AppSettingsProvider } from '@providers'; + +const makeDbContextWrapper = + (dbIntance: WalletDatabase): FunctionComponent => + ({ children }: { children?: React.ReactNode }) => + ( + + ({ environmentName: 'Preprod' } as any))}> + {children} + + + ); +describe('Testing useGetFilteredAddressBook hook', () => { + let db: WalletDatabase; + const mockAddress: AddressBookSchema[] = [ + { + id: 1, + address: 'addr_test1', + name: 'test wallet', + network: 0 + }, + { + id: 2, + address: 'addr_test2', + name: 'Other wallet', + network: 0 + }, + { + id: 3, + address: 'addr_test3', + name: 'Other wallet 2', + network: 0 + } + ]; + + beforeEach(async () => { + db = new WalletDatabase(); + db.getConnection(addressBookSchema).bulkAdd(mockAddress); + }); + afterEach(() => db.delete()); + + test('should be an empty array', () => { + const { result } = renderHook(() => useGetFilteredAddressBook(), { + wrapper: makeDbContextWrapper(db) + }); + expect(result.current.filteredAddresses).toHaveLength(0); + expect(result.current.filteredAddresses).toStrictEqual([]); + }); + + test('should filter by name and reset state', async () => { + const { result } = renderHook(() => useGetFilteredAddressBook(), { + wrapper: makeDbContextWrapper(db) + }); + expect(result.current.getAddressBookByNameOrAddress).toBeDefined(); + + await result.current.getAddressBookByNameOrAddress({ value: 't' }); + expect(result.current.filteredAddresses).toHaveLength(1); + expect(result.current.filteredAddresses).toStrictEqual([ + { id: 1, walletAddress: 'addr_test1', walletName: 'test wallet' } + ]); + + await act(() => result.current.resetAddressList()); + expect(result.current.filteredAddresses).toHaveLength(0); + expect(result.current.filteredAddresses).toStrictEqual([]); + }); + + test('should have limited results', async () => { + const { result } = renderHook(() => useGetFilteredAddressBook(), { + wrapper: makeDbContextWrapper(db) + }); + expect(result.current.getAddressBookByNameOrAddress).toBeDefined(); + + await result.current.getAddressBookByNameOrAddress({ value: 'oth' }); + expect(result.current.filteredAddresses).toHaveLength(2); + + await result.current.getAddressBookByNameOrAddress({ value: 'oth', limit: 1 }); + expect(result.current.filteredAddresses).toHaveLength(1); + }); + + test('should get result by exact address', async () => { + const { result } = renderHook(() => useGetFilteredAddressBook(), { + wrapper: makeDbContextWrapper(db) + }); + expect(result.current.getAddressBookByNameOrAddress).toBeDefined(); + + await result.current.getAddressBookByNameOrAddress({ value: 'addr_test3' }); + expect(result.current.filteredAddresses).toHaveLength(1); + expect(result.current.filteredAddresses).toStrictEqual([ + { id: 3, walletAddress: 'addr_test3', walletName: 'Other wallet 2' } + ]); + }); +}); diff --git a/apps/browser-extension-wallet/src/features/address-book/hooks/index.ts b/apps/browser-extension-wallet/src/features/address-book/hooks/index.ts new file mode 100644 index 0000000000..31e705f33f --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/hooks/index.ts @@ -0,0 +1 @@ +export * from './useGetFilteredAddressBook'; diff --git a/apps/browser-extension-wallet/src/features/address-book/hooks/useGetFilteredAddressBook.tsx b/apps/browser-extension-wallet/src/features/address-book/hooks/useGetFilteredAddressBook.tsx new file mode 100644 index 0000000000..5f71558cb3 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/hooks/useGetFilteredAddressBook.tsx @@ -0,0 +1,79 @@ +import { useDatabaseContext } from '@src/providers/DatabaseProvider'; +import { useState, useCallback, useRef, useEffect } from 'react'; +import { AddressBookSchema, addressBookSchema } from '@src/lib/storage'; +import { useActionExecution } from '@src/hooks/useActionExecution'; +import { useWalletStore } from '@src/stores'; +import { Wallet } from '@lace/cardano'; + +const DEFAULT_QUERY_LIMIT = 5; + +interface GetAddressByNameOrAddressArgs { + value: string; + limit?: number; +} + +interface FilteredAddressList { + id: number; + walletName: string; + walletAddress: string; +} + +const getAddressBookByNameOrAddressTransformer = ({ address, name, id }: AddressBookSchema): FilteredAddressList => ({ + walletAddress: address, + walletName: name, + id +}); + +export const useGetFilteredAddressBook = (): { + filteredAddresses: FilteredAddressList[]; + getAddressBookByNameOrAddress: ({ value, limit }: GetAddressByNameOrAddressArgs) => Promise; + resetAddressList: () => void; +} => { + const { environmentName } = useWalletStore(); + const [db] = useDatabaseContext(); + const [execute] = useActionExecution(); + const [filteredAddresses, setFilteredAddresses] = useState([]); + const dbRef = useRef(db); + const executeRef = useRef({ execute }); + + useEffect(() => { + dbRef.current = db; + }, [db]); + + useEffect(() => { + executeRef.current = { execute }; + }, [execute]); + + const getAddressBookByNameOrAddress = useCallback( + async ({ value, limit = DEFAULT_QUERY_LIMIT }: GetAddressByNameOrAddressArgs) => { + const network = + environmentName === 'Mainnet' ? Wallet.Cardano.NetworkId.Mainnet : Wallet.Cardano.NetworkId.Testnet; + + if (value.length <= 0) { + setFilteredAddresses([]); + } else { + await executeRef.current.execute(async () => { + const result = await dbRef.current + .getConnection(addressBookSchema) + .where('name') + .startsWithIgnoreCase(value) + .or('address') + .equalsIgnoreCase(value) + .filter((item) => item.network === network) + .limit(limit) + .toArray(); + + const addressList = result.map((element) => getAddressBookByNameOrAddressTransformer(element)); + setFilteredAddresses(addressList); + }); + } + }, + [environmentName] + ); + + const resetAddressList = useCallback(() => { + setFilteredAddresses([]); + }, []); + + return { filteredAddresses, getAddressBookByNameOrAddress, resetAddressList }; +}; diff --git a/apps/browser-extension-wallet/src/features/address-book/index.ts b/apps/browser-extension-wallet/src/features/address-book/index.ts new file mode 100644 index 0000000000..3227cf957f --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/index.ts @@ -0,0 +1 @@ +export * from './components/AddressBook'; diff --git a/apps/browser-extension-wallet/src/features/address-book/store/addressBookStore.tsx b/apps/browser-extension-wallet/src/features/address-book/store/addressBookStore.tsx new file mode 100644 index 0000000000..c026fe9445 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/store/addressBookStore.tsx @@ -0,0 +1,19 @@ +import create from 'zustand'; +import { AddressBookSchema } from '../../../lib/storage'; + +interface AddressBookStore { + addressToEdit?: AddressBookSchema | Omit; + isEditAddressVisible: boolean; + setIsEditAddressVisible: (visibility: boolean) => void; + setAddressToEdit: (address: AddressBookSchema | Omit) => void; +} + +/** + * returns a hook to access address book store states and setters + */ +export const useAddressBookStore = create((set) => ({ + addressToEdit: {} as AddressBookSchema, + isEditAddressVisible: false, + setIsEditAddressVisible: (visibility: boolean) => set({ isEditAddressVisible: visibility }), + setAddressToEdit: (address) => set({ addressToEdit: address }) +})); diff --git a/apps/browser-extension-wallet/src/features/address-book/store/index.tsx b/apps/browser-extension-wallet/src/features/address-book/store/index.tsx new file mode 100644 index 0000000000..e7b3b17e65 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/address-book/store/index.tsx @@ -0,0 +1 @@ +export * from './addressBookStore'; diff --git a/apps/browser-extension-wallet/src/features/assets/components/PopupAssets.tsx b/apps/browser-extension-wallet/src/features/assets/components/PopupAssets.tsx new file mode 100644 index 0000000000..456b5884ce --- /dev/null +++ b/apps/browser-extension-wallet/src/features/assets/components/PopupAssets.tsx @@ -0,0 +1,4 @@ +import React from 'react'; +import { AssetsView } from '.'; + +export const PopupAssets = (): React.ReactElement => ; diff --git a/apps/browser-extension-wallet/src/features/assets/components/index.ts b/apps/browser-extension-wallet/src/features/assets/components/index.ts new file mode 100644 index 0000000000..3ff019c976 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/assets/components/index.ts @@ -0,0 +1,2 @@ +export * from './PopupAssets'; +export * from '@src/views/browser-view/features/assets'; diff --git a/apps/browser-extension-wallet/src/features/assets/index.ts b/apps/browser-extension-wallet/src/features/assets/index.ts new file mode 100644 index 0000000000..07635cbbc8 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/assets/index.ts @@ -0,0 +1 @@ +export * from './components'; diff --git a/apps/browser-extension-wallet/src/features/dapp/components/BetaPill.module.scss b/apps/browser-extension-wallet/src/features/dapp/components/BetaPill.module.scss new file mode 100644 index 0000000000..2b614027a9 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/BetaPill.module.scss @@ -0,0 +1,15 @@ +@import '../../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../../packages/common/src/ui/styles/abstracts/mixins'; + +.pill { + margin-left: size_unit(1.5); + border-radius: size_unit(4); + padding: size_unit(1) size_unit(1.5); +} + +.betaPill { + @extend .pill; + background: var(--primary-gradient); + @include text-body-medium; + color: var(--text-color-white, #ffffff); +} diff --git a/apps/browser-extension-wallet/src/features/dapp/components/BetaPill.tsx b/apps/browser-extension-wallet/src/features/dapp/components/BetaPill.tsx new file mode 100644 index 0000000000..6bcb8663d7 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/BetaPill.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './BetaPill.module.scss'; + +export const BetaPill = (): JSX.Element => { + const { t } = useTranslation(); + return ( + + {t('core.dapp.beta')} + + ); +}; diff --git a/apps/browser-extension-wallet/src/features/dapp/components/ConfirmData.module.scss b/apps/browser-extension-wallet/src/features/dapp/components/ConfirmData.module.scss new file mode 100644 index 0000000000..bd9e82e001 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/ConfirmData.module.scss @@ -0,0 +1,56 @@ +@import '../../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../src/styles/rules/flex.scss'; +@import '../../../../../../packages/common/src/ui/styles/abstracts/_typography'; + +.actions { + display: flex; + gap: size_unit(1); + justify-content: space-evenly; + flex-direction: column; +} + +.spaceBetween { + justify-content: space-between; + padding-top: size_unit(2); +} + +.actions { + background-color: var(--bg-color-body); + @extend %flex-column; + justify-content: center; + gap: size_unit(1); + padding: size_unit(2) size_unit(3) size_unit(2) size_unit(3); + border-top: 2px solid var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey)); + margin: 0 size_unit(-3) size_unit(-2) size_unit(-3); + position: sticky; + bottom: 0; + .actionBtn { + width: 100%; + } +} + +.container { + display: flex; + flex-direction: column; + + .dappInfo { + margin-bottom: size_unit(2); + } + + .contentSection { + display: flex; + flex-direction: column; + color: var(--text-color-primary); + .heading { + @include text-body-semi-bold; + color: var(--text-color-primary); + } + + .pre { + overflow-x: hidden; + overflow-wrap: break-word; + white-space: pre-wrap; + color: var(--text-color-primary); + } + } +} diff --git a/apps/browser-extension-wallet/src/features/dapp/components/ConfirmData.tsx b/apps/browser-extension-wallet/src/features/dapp/components/ConfirmData.tsx new file mode 100644 index 0000000000..e061e46e46 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/ConfirmData.tsx @@ -0,0 +1,168 @@ +import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import { Wallet } from '@lace/cardano'; +import { Button } from '@lace/common'; +import { useTranslation } from 'react-i18next'; +import { Layout } from './Layout'; +import { sectionTitle, DAPP_VIEWS } from '../config'; +import styles from './ConfirmData.module.scss'; +import { useViewsFlowContext } from '@providers/ViewFlowProvider'; +import { DappInfo } from '@lace/core'; +import { consumeRemoteApi, exposeApi, RemoteApiPropertyType } from '@cardano-sdk/web-extension'; +import { runtime } from 'webextension-polyfill'; +import { DAPP_CHANNELS } from '@src/utils/constants'; +import { UserPromptService } from '@lib/scripts/background/services/dappService'; +import { of } from 'rxjs'; +import { HexBlob } from '@cardano-sdk/util'; +import { DappDataService } from '@lib/scripts/types'; +import { Skeleton } from 'antd'; +import { useRedirection } from '@hooks'; +import { dAppRoutePaths } from '@routes'; +import { useWalletStore } from '@stores'; + +const INDENT_SPACING = 2; +const DAPP_TOAST_DURATION = 50; + +const fromHex = (hexBlob: HexBlob): string => Buffer.from(hexBlob, 'hex').toString(); + +const hasJsonStructure = (str: string): boolean => { + if (typeof str !== 'string') return false; + try { + const result = JSON.parse(str); + const type = Object.prototype.toString.call(result); + return type === '[object Object]' || type === '[object Array]'; + } catch { + return false; + } +}; + +export const DappConfirmData = (): React.ReactElement => { + const { + utils: { setNextView }, + dappInfo + } = useViewsFlowContext(); + const { getKeyAgentType } = useWalletStore(); + const { t } = useTranslation(); + const [redirectToSignFailure] = useRedirection(dAppRoutePaths.dappTxSignFailure); + const [redirectToSignSuccess] = useRedirection(dAppRoutePaths.dappTxSignSuccess); + const [isConfirmingTx, setIsConfirmingTx] = useState(); + const isUsingHardwareWallet = useMemo( + () => getKeyAgentType() !== Wallet.KeyManagement.KeyAgentType.InMemory, + [getKeyAgentType] + ); + + const [formattedData, setFormattedData] = useState<{ + address: string; + dataToSign: string; + }>(); + + const cancelTransaction = useCallback(() => { + exposeApi>( + { + api$: of({ + async allowSignData(): Promise { + return Promise.resolve(false); + } + }), + baseChannel: DAPP_CHANNELS.userPrompt, + properties: { allowSignData: RemoteApiPropertyType.MethodReturningPromise } + }, + { logger: console, runtime } + ); + setTimeout(() => window.close(), DAPP_TOAST_DURATION); + }, []); + + useEffect(() => { + const dappDataApi = consumeRemoteApi>( + { + baseChannel: DAPP_CHANNELS.dappData, + properties: { + getSignDataData: RemoteApiPropertyType.MethodReturningPromise + } + }, + { logger: console, runtime } + ); + + dappDataApi + .getSignDataData() + .then((txData) => { + const dataFromHex = fromHex(txData.payload); + const txDataAddress = txData.addr.toString(); + const jsonStructureOrHexString = { + address: txDataAddress, + dataToSign: hasJsonStructure(dataFromHex) + ? JSON.stringify(JSON.parse(dataFromHex), undefined, INDENT_SPACING) + : dataFromHex + }; + setFormattedData(jsonStructureOrHexString); + }) + .catch((error) => { + console.log(error); + }); + }, []); + + const signWithHardwareWallet = useCallback(async () => { + setIsConfirmingTx(true); + try { + exposeApi>( + { + api$: of({ + async allowSignTx(): Promise { + return Promise.resolve(true); + } + }), + baseChannel: DAPP_CHANNELS.userPrompt, + properties: { allowSignTx: RemoteApiPropertyType.MethodReturningPromise } + }, + { logger: console, runtime } + ); + redirectToSignSuccess(); + } catch { + redirectToSignFailure(); + } + }, [setIsConfirmingTx, redirectToSignFailure, redirectToSignSuccess]); + + const confirmationCallback = useCallback( + () => (isUsingHardwareWallet ? signWithHardwareWallet() : setNextView()), + [isUsingHardwareWallet, signWithHardwareWallet, setNextView] + ); + + return ( + +
+ + {formattedData ? ( + <> +
+

Address:

+
{formattedData.address}
+
+
+

Data:

+
{formattedData.dataToSign}
+
+ + ) : ( + + )} +
+
+ + +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/features/dapp/components/ConfirmTransaction.module.scss b/apps/browser-extension-wallet/src/features/dapp/components/ConfirmTransaction.module.scss new file mode 100644 index 0000000000..cfac6eebe5 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/ConfirmTransaction.module.scss @@ -0,0 +1,29 @@ +@import '../../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../src/styles/rules/flex.scss'; + +.actions { + display: flex; + gap: size_unit(1); + justify-content: space-evenly; + flex-direction: column; +} + +.spaceBetween { + justify-content: space-between; + padding-top: size_unit(2); +} + +.actions { + background-color: var(--bg-color-body); + @extend %flex-column; + justify-content: center; + gap: size_unit(1); + padding: size_unit(2) size_unit(3) size_unit(2) size_unit(3); + border-top: 2px solid var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey)); + margin: 0 size_unit(-3) size_unit(-2) size_unit(-3); + position: sticky; + bottom: 0; + .actionBtn { + width: 100%; + } +} diff --git a/apps/browser-extension-wallet/src/features/dapp/components/ConfirmTransaction.tsx b/apps/browser-extension-wallet/src/features/dapp/components/ConfirmTransaction.tsx new file mode 100644 index 0000000000..59f9ddda7d --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/ConfirmTransaction.tsx @@ -0,0 +1,265 @@ +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { Button } from '@lace/common'; +import { useTranslation } from 'react-i18next'; +import { DappTransaction } from '@lace/core'; +import { Layout } from './Layout'; +import { useViewsFlowContext } from '@providers/ViewFlowProvider'; +import { sectionTitle, DAPP_VIEWS } from '../config'; +import styles from './ConfirmTransaction.module.scss'; +import { Wallet } from '@lace/cardano'; +import { useAddressBookContext, withAddressBookContext } from '@src/features/address-book/context'; +import { useWalletStore } from '@stores'; +import { AddressListType } from '@views/browser/features/activity'; +import { consumeRemoteApi, exposeApi, RemoteApiPropertyType } from '@cardano-sdk/web-extension'; +import { DappDataService } from '@lib/scripts/types'; +import { DAPP_CHANNELS } from '@src/utils/constants'; +import { runtime } from 'webextension-polyfill'; +import { useObservable } from '@hooks/useObservable'; +import { assetsBurnedInspector, assetsMintedInspector, createTxInspector } from '@cardano-sdk/core'; +import { Skeleton } from 'antd'; +import { useRedirection } from '@hooks'; +import { dAppRoutePaths } from '@routes'; +import { UserPromptService } from '@lib/scripts/background/services'; +import { of } from 'rxjs'; +import { CardanoTxOut } from '@src/types'; +import { getAssetsInformation, TokenInfo } from '@src/utils/get-assets-information'; +const DAPP_TOAST_DURATION = 50; + +const dappDataApi = consumeRemoteApi>( + { + baseChannel: DAPP_CHANNELS.dappData, + properties: { + getSignTxData: RemoteApiPropertyType.MethodReturningPromise + } + }, + { logger: console, runtime } +); + +// eslint-disable-next-line sonarjs/cognitive-complexity +export const ConfirmTransaction = withAddressBookContext((): React.ReactElement => { + const { + utils: { setNextView }, + dappInfo + } = useViewsFlowContext(); + const { t } = useTranslation(); + const { + walletInfo, + inMemoryWallet, + getKeyAgentType, + blockchainProvider: { assetProvider } + } = useWalletStore(); + const { list: addressList } = useAddressBookContext(); + + const [hasInsufficientFunds, setInsufficientFunds] = useState(false); + const [tx, setTx] = useState(); + const assets = useObservable(inMemoryWallet.assetInfo$); + const availableBalance = useObservable(inMemoryWallet.balance.utxo.available$); + const [errorMessage, setErrorMessage] = useState(); + const [redirectToSignFailure] = useRedirection(dAppRoutePaths.dappTxSignFailure); + const [isConfirmingTx, setIsConfirmingTx] = useState(); + const keyAgentType = getKeyAgentType(); + const isUsingHardwareWallet = useMemo( + () => keyAgentType !== Wallet.KeyManagement.KeyAgentType.InMemory, + [keyAgentType] + ); + const [assetsInfo, setAssetsInfo] = useState(); + + const getTransactionAssetsId = (outputs: CardanoTxOut[]) => { + const assetIds: Wallet.Cardano.AssetId[] = []; + const assetMaps = outputs.map((output) => output.value.assets); + for (const asset of assetMaps) { + if (asset) { + for (const id of asset.keys()) { + !assetIds.includes(id) && assetIds.push(id); + } + } + } + return assetIds; + }; + + const assetIds = useMemo(() => tx?.body?.outputs && getTransactionAssetsId(tx.body.outputs), [tx?.body?.outputs]); + + useEffect(() => { + const fetchAssetsInfo = async () => { + const result = await getAssetsInformation(assetIds, assets, { + assetProvider, + extraData: { nftMetadata: true, tokenMetadata: true } + }); + setAssetsInfo(result); + }; + fetchAssetsInfo(); + }, [assetIds, assetProvider, assets]); + + const cancelTransaction = useCallback(() => { + exposeApi>( + { + api$: of({ + async allowSignTx(): Promise { + return Promise.resolve(false); + } + }), + baseChannel: DAPP_CHANNELS.userPrompt, + properties: { allowSignTx: RemoteApiPropertyType.MethodReturningPromise } + }, + { logger: console, runtime } + ); + setTimeout(() => window.close(), DAPP_TOAST_DURATION); + }, []); + + const signWithHardwareWallet = useCallback(async () => { + setIsConfirmingTx(true); + try { + exposeApi>( + { + api$: of({ + async allowSignTx(): Promise { + return Promise.resolve(true); + } + }), + baseChannel: DAPP_CHANNELS.userPrompt, + properties: { allowSignTx: RemoteApiPropertyType.MethodReturningPromise } + }, + { logger: console, runtime } + ); + } catch { + redirectToSignFailure(); + } + }, [setIsConfirmingTx, redirectToSignFailure]); + + useEffect(() => { + dappDataApi + .getSignTxData() + .then(async (txData) => { + setTx(txData); + }) + .catch((error) => setErrorMessage(error)); + }, []); + + const createAssetList = useCallback( + (txAssets: Wallet.Cardano.TokenMap) => { + if (!assetsInfo) return []; + const assetList: Wallet.Cip30SignTxAssetItem[] = []; + // eslint-disable-next-line unicorn/no-array-for-each + txAssets.forEach(async (value, key) => { + const walletAsset = assets.get(key) || assetsInfo?.get(key); + if (!walletAsset) { + // Trying to use an asset not in wallet + setErrorMessage(t('core.dappTransaction.tryingToUseAssetNotInWallet')); + return; + } + assetList.push({ + name: walletAsset.name.toString() || key.toString(), + ticker: walletAsset.tokenMetadata?.ticker || walletAsset.nftMetadata?.name, + amount: Wallet.util.calculateAssetBalance(value, walletAsset) + }); + }); + return assetList; + }, + [assets, assetsInfo, t] + ); + + const addressToNameMap = useMemo( + () => new Map(addressList?.map((item: AddressListType) => [item.address, item.name])), + [addressList] + ); + + const txSummary: Wallet.Cip30SignTxSummary | undefined = useMemo(() => { + if (!tx) return; + const inspector = createTxInspector({ + minted: assetsMintedInspector, + burned: assetsBurnedInspector + }); + + const { minted, burned } = inspector(tx as Wallet.Cardano.HydratedTx); + const isMintTransaction = minted.length > 0; + const isBurnTransaction = burned.length > 0; + + // eslint-disable-next-line unicorn/no-nested-ternary + // TODO: improve + let txType: 'Send' | 'Mint' | 'Burn'; + if (isMintTransaction) { + txType = 'Mint'; + } else if (isBurnTransaction) { + txType = 'Burn'; + } else { + txType = 'Send'; + } + + const externalOutputs = tx.body.outputs.filter((output) => output.address !== walletInfo.address); + let totalCoins = BigInt(0); + + // eslint-disable-next-line unicorn/no-array-reduce + const txSummaryOutputs: Wallet.Cip30SignTxSummary['outputs'] = externalOutputs.reduce((acc, txOut) => { + // Don't show withdrawl tx's etc + if (txOut.address.toString() === walletInfo.address.toString()) return acc; + totalCoins += txOut.value.coins; + if (totalCoins >= availableBalance?.coins) { + setInsufficientFunds(true); + } + + return [ + ...acc, + { + coins: Wallet.util.lovelacesToAdaString(txOut.value.coins.toString()), + recipient: addressToNameMap?.get(txOut.address.toString()) || txOut.address.toString(), + ...(txOut.value.assets?.size > 0 && { assets: createAssetList(txOut.value.assets) }) + } + ]; + }, []); + + // eslint-disable-next-line consistent-return + return { + fee: Wallet.util.lovelacesToAdaString(tx.body.fee.toString()), + outputs: txSummaryOutputs, + type: txType + }; + }, [tx, availableBalance, walletInfo.address, createAssetList, addressToNameMap]); + + const translations = { + transaction: t('core.dappTransaction.transaction'), + amount: t('core.dappTransaction.amount'), + recipient: t('core.dappTransaction.recipient'), + fee: t('core.dappTransaction.fee'), + insufficientFunds: t('core.dappTransaction.insufficientFunds'), + adaFollowingNumericValue: t('general.adaFollowingNumericValue') + }; + + return ( + + {tx && txSummary ? ( + + ) : ( + + )} +
+ + +
+
+ ); +}); diff --git a/apps/browser-extension-wallet/src/features/dapp/components/Connect.module.scss b/apps/browser-extension-wallet/src/features/dapp/components/Connect.module.scss new file mode 100644 index 0000000000..b5fa444fbd --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/Connect.module.scss @@ -0,0 +1,128 @@ +@import '../../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../styles/rules/flex.scss'; +@import '../../../../../../packages/common/src/ui/styles/abstracts/_typography'; + +.spaceBetween { + justify-content: space-between; + padding-top: size_unit(2); +} + +.container { + justify-content: space-between; + flex: 1; + display: flex; + flex-direction: column; + + .dappInfo { + margin-top: size_unit(1); + } + + .imgPlaceholder { + align-items: center; + display: flex; + flex-direction: column; + flex: 1; + justify-content: center; + width: 70%; + text-align: center; + margin: size_unit(4) auto 0; + + .title { + font-weight: 600; + font-size: var(--heading); + line-height: size_unit(4); + text-align: center; + color: var(--text-color-black); + padding: 0 25px; + margin-top: size_unit(2); + } + + .description { + @include text-body-medium; + color: var(--text-color-secondary); + } + } + + .actions { + display: flex; + gap: size_unit(1); + } + + $btnPurple: var(--primary-default, #7f5af0); +} + +.footer { + background-color: var(--bg-color-white); + @extend %flex-column; + justify-content: center; + gap: size_unit(1); + padding: size_unit(2) size_unit(3) size_unit(2) size_unit(3); + border-top: 2px solid var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey)); + margin: 0 size_unit(-3) size_unit(-2) size_unit(-3); + position: sticky; + bottom: 0; + .footerBtn { + width: 100%; + } +} + +:global(#lace-app) { + .ant-modal-mask { + background: var(--light-mode-black, var(--dark-mode-bg-black)) !important; + } +} + +.dappConnection { + background: var(--light-mode-black, var(--dark-mode-bg-black)) !important; + box-shadow: 0 0 size_unit(2.5) rgba(0, 0, 0, 0.05), 0 0 size_unit(5) rgba(0, 0, 0, 0.05); + border-radius: size_unit(4); + display: flex; + flex-direction: column; + gap: size_unit(3); + + .modalContent { + display: flex; + flex-direction: column; + gap: size_unit(3); + } + + .modalSubheading { + @include text-subHeading-bold; + color: var(--text-color-primary); + text-align: center; + } + + .modalDescription { + @include text-body-medium; + color: var(--text-color-secondary); + text-align: center; + } + + .modalActions { + display: flex; + flex-direction: column; + gap: size_unit(2); + } + + :global(.ant-modal-content) { + background-color: var(--bg-color-modal); + box-shadow: var(--dark-mode-shadow-setup-box, var(--shadow-setup-box)) !important; + border-radius: size_unit(4) !important; + } + + :global(.ant-modal-body) { + display: flex; + flex-direction: column; + padding: size_unit(5) !important; + } +} + +.banner { + padding: size_unit(2) size_unit(3); + img { + margin-top: size_unit(2.5) !important; + } + span { + margin-left: -#{size_unit(1.5)} !important; + } +} diff --git a/apps/browser-extension-wallet/src/features/dapp/components/Connect.tsx b/apps/browser-extension-wallet/src/features/dapp/components/Connect.tsx new file mode 100644 index 0000000000..f3c2295c18 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/Connect.tsx @@ -0,0 +1,124 @@ +import React, { useState } from 'react'; +import { Button, useSearchParams } from '@lace/common'; +import { useTranslation } from 'react-i18next'; +import { Layout } from './Layout'; +import { AuthorizeDapp } from '@lace/core'; +import { sectionTitle, DAPP_VIEWS } from '../config'; +import styles from './Connect.module.scss'; +import Modal from 'antd/lib/modal/Modal'; +import { exposeApi, RemoteApiPropertyType } from '@cardano-sdk/web-extension'; +import { runtime } from 'webextension-polyfill'; +import { DAPP_CHANNELS } from '@src/utils/constants'; +import * as cip30 from '@cardano-sdk/dapp-connector'; +import { UserPromptService } from '@lib/scripts/background/services/dappService'; +import { of } from 'rxjs'; +import ShieldExclamation from '@assets/icons/shield-exclamation.svg'; +import { Banner } from '@components/Banner'; + +const DAPP_TOAST_DURATION = 50; + +const closeWindow = () => window.close(); + +const authorize = (authorization: 'deny' | 'just-once' | 'allow', url: string) => { + const api$ = of({ + allowOrigin(origin: cip30.Origin): Promise<'deny' | 'just-once' | 'allow'> { + /* eslint-disable-next-line promise/avoid-new */ + if (origin !== url) { + return Promise.reject(); + } + return Promise.resolve(authorization); + } + }); + + const userPromptService = exposeApi>( + { + api$, + baseChannel: DAPP_CHANNELS.userPrompt, + properties: { allowOrigin: RemoteApiPropertyType.MethodReturningPromise } + }, + { logger: console, runtime } + ); + + setTimeout(() => { + userPromptService.shutdown(); + closeWindow(); + }, DAPP_TOAST_DURATION); +}; + +export const Connect = (): React.ReactElement => { + const { t } = useTranslation(); + const [isModalVisible, setModalVisible] = useState(false); + + const { logo, url, name } = useSearchParams(['logo', 'url', 'name']); + + return ( + +
+ + } + /> +
+
+ + +
+ +
+
+ {t('dapp.connect.modal.header')} +
+
+ {t('dapp.connect.modal.description')} +
+
+ + +
+
+
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/features/dapp/components/DappTransactionFail.tsx b/apps/browser-extension-wallet/src/features/dapp/components/DappTransactionFail.tsx new file mode 100644 index 0000000000..a73acb83f4 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/DappTransactionFail.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@lace/common'; +import Fail from '../../../assets/icons/exclamation-circle.svg'; +import styles from './Layout.module.scss'; +import { Image } from 'antd'; + +export const DappTransactionFail = (): React.ReactElement => { + const { t } = useTranslation(); + + return ( +
+
+ +
{t('dapp.sign.failure.title')}
+
{t('dapp.sign.failure.description')}
+
+
+ +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/features/dapp/components/DappTransactionSuccess.tsx b/apps/browser-extension-wallet/src/features/dapp/components/DappTransactionSuccess.tsx new file mode 100644 index 0000000000..49cd3932a2 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/DappTransactionSuccess.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@lace/common'; +import { Image } from 'antd'; +import Success from '../../../assets/icons/success-staking.svg'; +import styles from './Layout.module.scss'; + +export const DappTransactionSuccess = (): React.ReactElement => { + const { t } = useTranslation(); + + return ( +
+
+ +
{t('browserView.transaction.success.youCanSafelyCloseThisPanel')}
+
{t('core.dappTransaction.signedSuccessfully')}
+
+
+ +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/features/dapp/components/Layout.module.scss b/apps/browser-extension-wallet/src/features/dapp/components/Layout.module.scss new file mode 100644 index 0000000000..edad913a6a --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/Layout.module.scss @@ -0,0 +1,79 @@ +@import '../../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../styles/rules/flex.scss'; + +.layout { + background: var(--bg-color-body, #ffffff); + display: flex; + flex: 1; + flex-direction: column; + padding: size_unit(2) size_unit(3) size_unit(2) size_unit(3); +} + +.title { + align-items: center; + color: var(--text-color-primary); + display: flex; + font-weight: 700; + font-size: var(--heading); + line-height: size_unit(4); + letter-spacing: -0.015em; +} + +.page { + display: flex; + flex: 1; + flex-direction: column; + padding-top: size_unit(4); +} + +.noWalletContainer { + align-items: center; + display: flex; + flex-direction: column; + height: 100%; + justify-content: space-between; + width: 100%; + + .noWalletContent { + padding: 0 size_unit(3); + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + align-items: center; + + .heading { + color: var(--text-color-secondary); + font-size: var(--bodyLarge); + font-weight: 800; + letter-spacing: 0.02em; + line-height: size_unit(3); + margin-top: size_unit(2); + text-align: center; + } + + .description { + color: var(--text-color-secondary); + font-size: var(--bodySmall); + letter-spacing: 0.02em; + line-height: size_unit(3); + margin-top: size_unit(2); + text-align: center; + padding: 0 size_unit(4); + } + } + + .footer { + background-color: var(--color-white); + @extend %flex-column; + justify-content: center; + gap: size_unit(1); + margin-top: size_unit(2); + padding: size_unit(2) size_unit(3); + border-top: 2px solid var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey)); + width: 100%; + .footerBtn { + width: 100%; + } + } +} diff --git a/apps/browser-extension-wallet/src/features/dapp/components/Layout.tsx b/apps/browser-extension-wallet/src/features/dapp/components/Layout.tsx new file mode 100644 index 0000000000..78b3e6b735 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/Layout.tsx @@ -0,0 +1,19 @@ +import classnames from 'classnames'; +import React from 'react'; +import styles from './Layout.module.scss'; + +type layoutProps = { + title: string | React.ReactElement; + children?: React.ReactElement | React.ReactNode; + layoutClassname?: string; + pageClassname?: string; +}; + +export const Layout = ({ title, children, layoutClassname, pageClassname }: layoutProps): React.ReactElement => ( +
+
+ {title} +
+
{children}
+
+); diff --git a/apps/browser-extension-wallet/src/features/dapp/components/NoWallet.tsx b/apps/browser-extension-wallet/src/features/dapp/components/NoWallet.tsx new file mode 100644 index 0000000000..48991b1697 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/NoWallet.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Image } from 'antd'; +import { useTranslation } from 'react-i18next'; +import Empty from '../../../assets/icons/empty.svg'; +import styles from './Layout.module.scss'; +import { Button } from '@lace/common'; +import { tabs } from 'webextension-polyfill'; + +const openCreatePage = () => { + tabs.create({ url: 'app.html#/setup' }); + window.close(); +}; + +export const NoWallet = (): React.ReactElement => { + const { t } = useTranslation(); + + return ( +
+
+ +
{t('dapp.noWallet.heading')}
+
{t('dapp.noWallet.description')}
+
+
+ +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/features/dapp/components/SignData.tsx b/apps/browser-extension-wallet/src/features/dapp/components/SignData.tsx new file mode 100644 index 0000000000..8a6267f114 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/SignData.tsx @@ -0,0 +1,97 @@ +import React, { useCallback, useState, useMemo } from 'react'; +import { Spin } from 'antd'; +import { Wallet } from '@lace/cardano'; +import { useTranslation } from 'react-i18next'; +import { Button, inputProps, Password } from '@lace/common'; +import { useWalletStore } from '@stores'; +import { useRedirection, useWalletManager } from '@hooks'; +import { dAppRoutePaths } from '@routes'; +import { Layout } from './Layout'; +import { useViewsFlowContext } from '@providers/ViewFlowProvider'; +import styles from './SignTransaction.module.scss'; +import { exposeApi, RemoteApiPropertyType } from '@cardano-sdk/web-extension'; +import { DAPP_CHANNELS } from '@src/utils/constants'; +import { runtime } from 'webextension-polyfill'; +import { UserPromptService } from '@lib/scripts/background/services'; +import { of } from 'rxjs'; + +export const SignData = (): React.ReactElement => { + const { t } = useTranslation(); + const { + utils: { setPreviousView } + } = useViewsFlowContext(); + const [redirectToSignFailure] = useRedirection(dAppRoutePaths.dappTxSignFailure); + const [redirectToSignSuccess] = useRedirection(dAppRoutePaths.dappTxSignSuccess); + const { executeWithPassword } = useWalletManager(); + const [isLoading, setIsLoading] = useState(false); + const [password, setPassword] = useState(); + const [validPassword, setValidPassword] = useState(); + const { keyAgentData } = useWalletStore(); + + const handleVerifyPass = useCallback(async () => { + setIsLoading(true); + try { + const valid = await Wallet.validateWalletPassword(keyAgentData, password); + setValidPassword(valid); + if (!valid) { + setIsLoading(false); + return; + } + exposeApi>( + { + api$: of({ + allowSignData(): Promise { + return Promise.resolve(valid); + } + }), + baseChannel: DAPP_CHANNELS.userPrompt, + properties: { allowSignData: RemoteApiPropertyType.MethodReturningPromise } + }, + { logger: console, runtime } + ); + redirectToSignSuccess(); + } catch { + redirectToSignFailure(); + } finally { + setIsLoading(false); + } + }, [password, redirectToSignFailure, keyAgentData, redirectToSignSuccess]); + + const onConfirm = useCallback( + () => executeWithPassword(password, handleVerifyPass, false), + [executeWithPassword, handleVerifyPass, password] + ); + + const handleChange: inputProps['onChange'] = ({ target: { value } }) => setPassword(value); + + const confirmIsDisabled = useMemo(() => { + if (keyAgentData.__typename !== 'InMemory') return false; + return !password; + }, [keyAgentData.__typename, password]); + + return ( + +
+ +
+ {t('browserView.transaction.send.enterWalletPasswordToConfirmTransaction')} +
+ +
+
+
+ + +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/features/dapp/components/SignDataFlowContainer.tsx b/apps/browser-extension-wallet/src/features/dapp/components/SignDataFlowContainer.tsx new file mode 100644 index 0000000000..c1fcf64544 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/SignDataFlowContainer.tsx @@ -0,0 +1,18 @@ +/* eslint-disable react/no-multi-comp */ +import React from 'react'; +import { signDataViewsFlowState } from '../config'; +import { useViewsFlowContext, ViewFlowProvider } from '../../../providers'; + +const DappView = (): React.ReactElement => { + const { utils } = useViewsFlowContext(); + const { renderCurrentView } = utils; + const CurrentViewComponent = renderCurrentView(); + + return ; +}; + +export const SignDataFlowContainer = (): React.ReactElement => ( + + + +); diff --git a/apps/browser-extension-wallet/src/features/dapp/components/SignTransaction.module.scss b/apps/browser-extension-wallet/src/features/dapp/components/SignTransaction.module.scss new file mode 100644 index 0000000000..74f10b5d05 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/SignTransaction.module.scss @@ -0,0 +1,36 @@ +@import '../../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../src/styles/rules/flex.scss'; + +.passwordContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; +} + +.message { + font-weight: 600; + font-size: var(--bodyLarge); + line-height: size_unit(3); + letter-spacing: -0.015em; + display: flex; + align-items: center; + text-align: center; + color: var(--text-color-black); +} + +.actions { + background-color: var(--bg-color-body); + @extend %flex-column; + justify-content: center; + gap: size_unit(1); + padding: size_unit(2) size_unit(3) size_unit(2) size_unit(3); + border-top: 2px solid var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey)); + margin: 0 size_unit(-3) size_unit(-2) size_unit(-3); + position: sticky; + bottom: 0; + .actionBtn { + width: 100%; + } +} diff --git a/apps/browser-extension-wallet/src/features/dapp/components/SignTransaction.tsx b/apps/browser-extension-wallet/src/features/dapp/components/SignTransaction.tsx new file mode 100644 index 0000000000..623a33ff5e --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/SignTransaction.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useState, useMemo } from 'react'; +import { Spin } from 'antd'; +import { Wallet } from '@lace/cardano'; +import { useTranslation } from 'react-i18next'; +import { Button, inputProps, Password } from '@lace/common'; +import { useWalletStore } from '@stores'; +import { useRedirection, useWalletManager } from '@hooks'; +import { dAppRoutePaths } from '@routes'; +import { Layout } from './Layout'; +import { useViewsFlowContext } from '@providers/ViewFlowProvider'; +import styles from './SignTransaction.module.scss'; +import { exposeApi, RemoteApiPropertyType } from '@cardano-sdk/web-extension'; +import { DAPP_CHANNELS } from '@src/utils/constants'; +import { runtime } from 'webextension-polyfill'; +import { UserPromptService } from '@lib/scripts/background/services'; +import { of } from 'rxjs'; + +export const SignTransaction = (): React.ReactElement => { + const { t } = useTranslation(); + const { + utils: { setPreviousView } + } = useViewsFlowContext(); + const [redirectToSignFailure] = useRedirection(dAppRoutePaths.dappTxSignFailure); + const { executeWithPassword } = useWalletManager(); + const [isLoading, setIsLoading] = useState(false); + const [password, setPassword] = useState(); + const [validPassword, setValidPassword] = useState(); + const { keyAgentData } = useWalletStore(); + + const handleVerifyPass = useCallback(async () => { + setIsLoading(true); + try { + const valid = await Wallet.validateWalletPassword(keyAgentData, password); + setValidPassword(valid); + if (!valid) return; + exposeApi>( + { + api$: of({ + allowSignTx(): Promise { + return Promise.resolve(valid); + } + }), + baseChannel: DAPP_CHANNELS.userPrompt, + properties: { allowSignTx: RemoteApiPropertyType.MethodReturningPromise } + }, + { logger: console, runtime } + ); + } catch { + redirectToSignFailure(); + } finally { + setIsLoading(false); + } + }, [password, redirectToSignFailure, keyAgentData]); + + const onConfirm = useCallback( + () => executeWithPassword(password, handleVerifyPass, false), + [executeWithPassword, handleVerifyPass, password] + ); + + const handleChange: inputProps['onChange'] = ({ target: { value } }) => setPassword(value); + + const confirmIsDisabled = useMemo(() => { + if (keyAgentData.__typename !== 'InMemory') return false; + return !password; + }, [keyAgentData.__typename, password]); + + return ( + +
+ +
+ {t('browserView.transaction.send.enterWalletPasswordToConfirmTransaction')} +
+ +
+
+
+ + +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/features/dapp/components/SignTxFlowContainer.tsx b/apps/browser-extension-wallet/src/features/dapp/components/SignTxFlowContainer.tsx new file mode 100644 index 0000000000..e45e0222fa --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/SignTxFlowContainer.tsx @@ -0,0 +1,18 @@ +/* eslint-disable react/no-multi-comp */ +import React from 'react'; +import { sendViewsFlowState } from '../config'; +import { useViewsFlowContext, ViewFlowProvider } from '../../../providers'; + +const DappView = (): React.ReactElement => { + const { utils } = useViewsFlowContext(); + const { renderCurrentView } = utils; + const CurrentViewComponent = renderCurrentView(); + + return ; +}; + +export const SignTxFlowContainer = (): React.ReactElement => ( + + + +); diff --git a/apps/browser-extension-wallet/src/features/dapp/components/index.ts b/apps/browser-extension-wallet/src/features/dapp/components/index.ts new file mode 100644 index 0000000000..f7b1f3000a --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/components/index.ts @@ -0,0 +1,8 @@ +export * from './Connect'; +export * from './SignTxFlowContainer'; +export * from './DappTransactionSuccess'; +export * from './DappTransactionFail'; +export * from './NoWallet'; +export * from './ConfirmData'; +export * from './BetaPill'; +export * from './SignDataFlowContainer'; diff --git a/apps/browser-extension-wallet/src/features/dapp/config/ViewsConfig.tsx b/apps/browser-extension-wallet/src/features/dapp/config/ViewsConfig.tsx new file mode 100644 index 0000000000..34624df137 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/config/ViewsConfig.tsx @@ -0,0 +1,85 @@ +import { IViewsList } from '../../../types'; +import { ConfirmTransaction } from '../components/ConfirmTransaction'; +import { SignTransaction } from '../components/SignTransaction'; +import { DappTransactionFail } from '../components/DappTransactionFail'; +import { IViewAction, IViewState } from '../../../providers'; +import { DappConfirmData as ConfirmData } from '../components/ConfirmData'; +import { SignData } from '../components/SignData'; +export enum DAPP_VIEWS { + CONNECT = 'connect', + CONFIRM_TX = 'confirm-tx', + TX_SIGN = 'tx-sign', + TX_SIGN_SUCCESS = 'tx-sign-success', + TX_SIGN_FAILURE = 'tx-sign-failure', + CONFIRM_DATA = 'confirm-data', + SIGN_DATA = 'sign-data' +} + +export const sendViewList: IViewsList = { + [DAPP_VIEWS.CONFIRM_TX]: ConfirmTransaction, + [DAPP_VIEWS.TX_SIGN]: SignTransaction, + [DAPP_VIEWS.TX_SIGN_FAILURE]: DappTransactionFail +}; + +export const getSendViewComponent: IViewAction = (currentView: DAPP_VIEWS) => sendViewList[currentView]; + +export const sendViewsFlowState: IViewState = { + initial: DAPP_VIEWS.CONFIRM_TX, + states: { + [DAPP_VIEWS.CONFIRM_TX]: { + prev: DAPP_VIEWS.CONFIRM_TX, + next: DAPP_VIEWS.TX_SIGN, + action: getSendViewComponent + }, + [DAPP_VIEWS.TX_SIGN]: { + next: DAPP_VIEWS.CONFIRM_TX, + prev: DAPP_VIEWS.CONFIRM_TX, + action: getSendViewComponent + }, + [DAPP_VIEWS.TX_SIGN_FAILURE]: { + next: DAPP_VIEWS.TX_SIGN_FAILURE, + prev: DAPP_VIEWS.CONFIRM_TX, + action: getSendViewComponent + } + } +}; + +export const signDataViewList: IViewsList = { + [DAPP_VIEWS.CONFIRM_DATA]: ConfirmData, + [DAPP_VIEWS.SIGN_DATA]: SignData, + [DAPP_VIEWS.TX_SIGN_FAILURE]: DappTransactionFail +}; + +export const getSignDataViewComponent: IViewAction = (currentView: DAPP_VIEWS) => + signDataViewList[currentView]; + +export const signDataViewsFlowState: IViewState = { + initial: DAPP_VIEWS.CONFIRM_DATA, + states: { + [DAPP_VIEWS.CONFIRM_DATA]: { + prev: DAPP_VIEWS.CONFIRM_DATA, + next: DAPP_VIEWS.SIGN_DATA, + action: getSignDataViewComponent + }, + [DAPP_VIEWS.SIGN_DATA]: { + next: DAPP_VIEWS.CONFIRM_DATA, + prev: DAPP_VIEWS.CONFIRM_DATA, + action: getSignDataViewComponent + }, + [DAPP_VIEWS.TX_SIGN_FAILURE]: { + next: DAPP_VIEWS.TX_SIGN_FAILURE, + prev: DAPP_VIEWS.CONFIRM_DATA, + action: getSignDataViewComponent + } + } +}; + +export const sectionTitle: Record = { + [DAPP_VIEWS.CONNECT]: 'dapp.connect.header', + [DAPP_VIEWS.CONFIRM_TX]: 'dapp.confirm.header', + [DAPP_VIEWS.TX_SIGN]: 'dapp.sign.header', + [DAPP_VIEWS.TX_SIGN_SUCCESS]: 'dapp.sign.success.header', + [DAPP_VIEWS.TX_SIGN_FAILURE]: 'dapp.sign.failure.header', + [DAPP_VIEWS.CONFIRM_DATA]: 'dapp.confirm.header.confirmData', + [DAPP_VIEWS.SIGN_DATA]: 'dapp.confirm.header.signData' +}; diff --git a/apps/browser-extension-wallet/src/features/dapp/config/index.ts b/apps/browser-extension-wallet/src/features/dapp/config/index.ts new file mode 100644 index 0000000000..55d8b88884 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/config/index.ts @@ -0,0 +1 @@ +export * from './ViewsConfig'; diff --git a/apps/browser-extension-wallet/src/features/dapp/context/DappProvider.tsx b/apps/browser-extension-wallet/src/features/dapp/context/DappProvider.tsx new file mode 100644 index 0000000000..27a16b877f --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/context/DappProvider.tsx @@ -0,0 +1,49 @@ +/* eslint-disable react/no-multi-comp */ +import React, { useState, useEffect } from 'react'; +import { DappContext } from './context'; +import { AuthorizedDappService } from '@src/types'; +import { DAPP_CHANNELS } from '@src/utils/constants'; +import { consumeRemoteApi, RemoteApiPropertyType } from '@cardano-sdk/web-extension'; +import { runtime } from 'webextension-polyfill'; +import { Wallet } from '@lace/cardano'; +import isEqual from 'lodash/isEqual'; + +interface DappProviderProps { + children: React.ReactNode; + initialState?: Wallet.DappInfo[]; +} + +export const DappProvider = ({ children, initialState }: DappProviderProps): React.ReactElement => { + const [dappList, setDappList] = useState(initialState); + useEffect(() => { + const authorizedDappService = consumeRemoteApi>( + { + baseChannel: DAPP_CHANNELS.authorizedDapps, + properties: { + authorizedDappsList: RemoteApiPropertyType.HotObservable + } + }, + { logger: console, runtime } + ); + + authorizedDappService?.authorizedDappsList.subscribe((dapps) => { + if (!isEqual(dappList, dapps)) { + setDappList(dapps); + } + }); + }, [dappList]); + + return {children}; +}; + +type WithDappContext = ( + element: React.JSXElementConstructor, + initialState?: Wallet.DappInfo[] +) => (props?: TProps) => React.ReactElement; + +export const withDappContext: WithDappContext = (Component, initialState) => (props) => + ( + + + + ); diff --git a/apps/browser-extension-wallet/src/features/dapp/context/context.ts b/apps/browser-extension-wallet/src/features/dapp/context/context.ts new file mode 100644 index 0000000000..d4243f758f --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/context/context.ts @@ -0,0 +1,10 @@ +import { createContext, useContext } from 'react'; +import { Wallet } from '@lace/cardano'; +// eslint-disable-next-line unicorn/no-null +export const DappContext = createContext(null); + +export const useDappContext: () => Wallet.DappInfo[] = () => { + const dappContext = useContext(DappContext); + if (dappContext === null) throw new Error('DappContext is not defined.'); + return dappContext; +}; diff --git a/apps/browser-extension-wallet/src/features/dapp/context/index.ts b/apps/browser-extension-wallet/src/features/dapp/context/index.ts new file mode 100644 index 0000000000..900504ba47 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/context/index.ts @@ -0,0 +1,2 @@ +export * from './context'; +export * from './DappProvider'; diff --git a/apps/browser-extension-wallet/src/features/dapp/index.ts b/apps/browser-extension-wallet/src/features/dapp/index.ts new file mode 100644 index 0000000000..07635cbbc8 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/dapp/index.ts @@ -0,0 +1 @@ +export * from './components'; diff --git a/apps/browser-extension-wallet/src/features/delegation/api/__tests__/transformers.test.ts b/apps/browser-extension-wallet/src/features/delegation/api/__tests__/transformers.test.ts new file mode 100644 index 0000000000..74c4b7b12b --- /dev/null +++ b/apps/browser-extension-wallet/src/features/delegation/api/__tests__/transformers.test.ts @@ -0,0 +1,11 @@ +import { stakePoolTransformer } from '../transformers'; +import { cardanoStakePoolMock, transformedStakePool } from '../../../../utils/mocks/test-helpers'; +import { cardanoCoin } from '@src/utils/constants'; + +describe('Testing transformers', () => { + test('should return proper data form stakePoolTransformer', () => { + expect(stakePoolTransformer({ stakePool: cardanoStakePoolMock.pageResults[0], cardanoCoin })).toEqual( + transformedStakePool + ); + }); +}); diff --git a/apps/browser-extension-wallet/src/features/delegation/api/transformers.ts b/apps/browser-extension-wallet/src/features/delegation/api/transformers.ts new file mode 100644 index 0000000000..7fc45f6d2c --- /dev/null +++ b/apps/browser-extension-wallet/src/features/delegation/api/transformers.ts @@ -0,0 +1,42 @@ +/* eslint-disable no-magic-numbers */ +import { Wallet } from '@lace/cardano'; +import { StakePool } from '../types'; +import { getRandomIcon } from '@src/utils/get-random-icon'; +import { formatPercentages } from '@src/utils/format-number'; +import { CoinId } from '@src/types'; + +type StakePoolTransformerProp = { + stakePool: Wallet.Cardano.StakePool; + delegatingPoolId?: string; + cardanoCoin: CoinId; +}; + +export const stakePoolTransformer = ({ + stakePool, + delegatingPoolId, + cardanoCoin +}: StakePoolTransformerProp): StakePool => { + const { margin, cost, hexId, pledge, owners, status, metadata, id, metrics } = stakePool; + const { size, apy, saturation } = metrics; + const calcCost = cost ? ` + ${Wallet.util.lovelacesToAdaString(cost.toString(), 0)}${cardanoCoin.symbol}` : ''; + const calcMargin = margin ? `${formatPercentages(margin.numerator / margin.denominator)}` : '-'; + + return { + hexId: hexId.toString(), + margin: calcMargin, + pledge: pledge ? `${Wallet.util.lovelacesToAdaString(pledge.toString())}${cardanoCoin.symbol}` : '-', + owners: owners ? owners.map((owner: Wallet.Cardano.RewardAccount) => owner.toString()) : [], + retired: status === Wallet.Cardano.StakePoolStatus.Retired, + description: metadata?.description, + size: `${size?.live ?? '-'} %`, + id: id.toString(), + cost: `${calcMargin}%${calcCost}`, + name: metadata?.name, + ticker: metadata?.ticker, + logo: metadata?.ext?.pool.media_assets?.icon_png_64x64 || getRandomIcon({ id: id.toString(), size: 30 }), + apy: apy && formatPercentages(apy.valueOf()), + saturation: saturation && formatPercentages(saturation.valueOf()), + fee: Wallet.util.lovelacesToAdaString(cost.toString()), + isStakingPool: delegatingPoolId ? delegatingPoolId === id.toString() : undefined + }; +}; diff --git a/apps/browser-extension-wallet/src/features/delegation/components/DelegationContainer.tsx b/apps/browser-extension-wallet/src/features/delegation/components/DelegationContainer.tsx new file mode 100644 index 0000000000..c78bdd16e1 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/delegation/components/DelegationContainer.tsx @@ -0,0 +1,177 @@ +/* eslint-disable max-statements */ +/* eslint-disable unicorn/no-nested-ternary */ +/* eslint-disable complexity */ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import isNumber from 'lodash/isNumber'; +import { Wallet } from '@lace/cardano'; +import { walletRoutePaths } from '@routes'; +import { + useRedirection, + useObservable, + useBalances, + useFetchCoinPrice, + useDelegationDetails, + useStakingRewards +} from '@hooks'; +import { useWalletStore } from '@stores'; +import { networkInfoStatusSelector, stakePoolResultsSelector } from '@stores/selectors/staking-selectors'; +import { walletBalanceTransformer } from '@src/api/transformers'; +import { StakePoolDetails, StakingModals } from '../../stake-pool-details'; +import { Sections } from '@views/browser/features/staking/types'; +import { useStakePoolDetails } from '../../stake-pool-details/store'; +import { stakePoolTransformer } from '../api/transformers'; +import { useDelegationStore } from '../stores'; +import { DelegationLayout } from './DelegationLayout'; + +import { TransitionAcknowledgmentDialog } from '@components/TransitionAcknowledgmentDialog'; +import { useTranslation } from 'react-i18next'; +import { + AnalyticsEventActions, + AnalyticsEventCategories, + AnalyticsEventNames +} from '@providers/AnalyticsProvider/analyticsTracker'; +import { useAnalyticsContext } from '@providers'; + +const STORAGE_MEMO_ENTRY_NAME = 'hideStakingHwDialog'; +const MIN_CHARS_TO_SEARCH = 3; +const MAX_ITEMS_TO_SHOW = 3; +const PoolDetailsStepsWithExitBtn = new Set([ + Sections.CONFIRMATION, + Sections.SIGN, + Sections.FAIL_TX, + Sections.SUCCESS_TX +]); +const PoolDetailsStepsWithExitConfirm = new Set([Sections.CONFIRMATION, Sections.SIGN]); +const PoolDetailsStepsWithBackBtn = new Set([Sections.DETAIL, Sections.CONFIRMATION, Sections.SIGN]); + +export const DelegationContainer = (): React.ReactElement => { + const { t } = useTranslation(); + const { + getKeyAgentType, + walletUI: { cardanoCoin } + } = useWalletStore(); + const isInMemory = useMemo(() => getKeyAgentType() === Wallet.KeyManagement.KeyAgentType.InMemory, [getKeyAgentType]); + const [searchValue, setSearchValue] = useState(); + const [redirectToReceive] = useRedirection(walletRoutePaths.receive); + const dialogHiddenByUser = localStorage.getItem(STORAGE_MEMO_ENTRY_NAME) === 'true'; + const shouldShowAcknowledgmentDialog = !dialogHiddenByUser && !isInMemory; + const [isTransitionAcknowledgmentDialogVisible, setIsTransitionAcknowledgmentDialogVisible] = + useState(shouldShowAcknowledgmentDialog); + const toggleisTransitionAcknowledgmentDialog = () => + setIsTransitionAcknowledgmentDialogVisible(!isTransitionAcknowledgmentDialogVisible); + + const isLoadingNetworkInfo = useWalletStore(networkInfoStatusSelector); + const { stakePoolSearchResults, isSearching, fetchStakePools } = useWalletStore(stakePoolResultsSelector); + + const { setSelectedStakePool } = useDelegationStore(); + const { setStakeConfirmationVisible, setIsDrawerVisible, setSection } = useStakePoolDetails(); + + const { inMemoryWallet, walletInfo } = useWalletStore(); + const { totalRewards, lastReward } = useStakingRewards(); + const rewardAccounts = useObservable(inMemoryWallet.delegation.rewardAccounts$); + const protocolParameters = useObservable(inMemoryWallet?.protocolParameters$); + + const { coinBalance: minAda } = walletBalanceTransformer(protocolParameters?.stakeKeyDeposit.toString()); + const { priceResult } = useFetchCoinPrice(); + const { balance } = useBalances(priceResult?.cardano?.price); + const analytics = useAnalyticsContext(); + + const delegationDetails = useDelegationDetails(); + const isStakeRegistered = rewardAccounts && rewardAccounts[0].keyStatus === Wallet.StakeKeyStatus.Registered; + const coinBalance = balance?.total?.coinBalance && Number(balance?.total?.coinBalance); + const hasNoFunds = (coinBalance < Number(minAda) && !isStakeRegistered) || (coinBalance === 0 && isStakeRegistered); + const isDelegating = !!(rewardAccounts && delegationDetails); + const canDelegate = !isDelegating && isNumber(coinBalance) && !hasNoFunds; + + const handleSearch = (val: string) => setSearchValue(val ?? ''); + + const onStakePoolSelect = (pool: Wallet.Cardano.StakePool) => { + setSelectedStakePool(pool); + setIsDrawerVisible(true); + }; + + const parseStakePools = () => + stakePoolSearchResults?.pageResults.map((pool) => + stakePoolTransformer({ stakePool: pool, delegatingPoolId: delegationDetails?.id?.toString(), cardanoCoin }) + ); + + useEffect(() => { + const hasPersistedHwStakepool = !!localStorage.getItem('TEMP_POOLID'); + const isHardwareWalletPopupTransition = !isInMemory && hasPersistedHwStakepool; + // `hasPersistedHwStakepool` will get immidiately unset once the HW transition is over. + if (isHardwareWalletPopupTransition) return; + if (searchValue?.length !== 0 && searchValue?.length < MIN_CHARS_TO_SEARCH) return; + fetchStakePools({ searchString: searchValue || '', limit: MAX_ITEMS_TO_SHOW }); + }, [searchValue, fetchStakePools, isInMemory]); + + const openDelagationConfirmation = useCallback(() => { + setSection(); + }, [setSection]); + + const onStake = useCallback(() => { + if (isDelegating) { + setStakeConfirmationVisible(true); + return; + } + + openDelagationConfirmation(); + setIsDrawerVisible(true); + }, [isDelegating, setStakeConfirmationVisible, openDelagationConfirmation, setIsDrawerVisible]); + + const sendAnalytics = () => { + analytics.sendEvent({ + category: AnalyticsEventCategories.STAKING, + action: AnalyticsEventActions.CLICK_EVENT, + name: AnalyticsEventNames.Staking.VIEW_STAKEPOOL_INFO_POPUP + }); + }; + + return ( + <> + + = MIN_CHARS_TO_SEARCH ? parseStakePools() : []} + coinBalance={coinBalance} + searchValue={searchValue} + handleSearchChange={handleSearch} + handleAddFunds={redirectToReceive} + currentStakePool={delegationDetails && stakePoolTransformer({ stakePool: delegationDetails, cardanoCoin })} + isLoading={isLoadingNetworkInfo} + totalRewards={Wallet.util.lovelacesToAdaString(totalRewards.toString())} + lastReward={Wallet.util.lovelacesToAdaString(lastReward.toString())} + isSearching={isSearching} + hasNoFunds={hasNoFunds} + isDelegating={isDelegating} + canDelegate={canDelegate} + walletAddress={walletInfo?.address} + fiat={priceResult?.cardano?.price} + onStakePoolSelect={() => onStakePoolSelect(delegationDetails)} + onStakePoolClick={(poolId: string) => { + sendAnalytics(); + setSearchValue(''); + setSelectedStakePool( + stakePoolSearchResults.pageResults.find((pool: Wallet.Cardano.StakePool) => pool?.id?.toString() === poolId) + ); + setIsDrawerVisible(true); + }} + cardanoCoin={cardanoCoin} + /> + PoolDetailsStepsWithExitBtn.has(section)} + showBackIcon={(section: Sections): boolean => PoolDetailsStepsWithBackBtn.has(section)} + showExitConfirmation={(section: Sections): boolean => PoolDetailsStepsWithExitConfirm.has(section)} + canDelegate={!hasNoFunds} + onStake={onStake} + popupView + /> + + + ); +}; diff --git a/apps/browser-extension-wallet/src/features/delegation/components/DelegationLayout.module.scss b/apps/browser-extension-wallet/src/features/delegation/components/DelegationLayout.module.scss new file mode 100644 index 0000000000..6fb17c1da0 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/delegation/components/DelegationLayout.module.scss @@ -0,0 +1,64 @@ +@import '../../../../../../packages/common/src/ui/styles/theme.scss'; +@import '../../../../../../packages/common/src/ui/styles/abstracts/typography'; + +.contentWrapper { + display: flex; + flex-direction: column; + gap: size_unit(4); +} + +.content { + flex-direction: column; + padding-bottom:size_unit(3.5); +} + +.header { + align-items: baseline; + color: var(--text-color-primary); + display: flex; + font-size: var(--heading); + font-weight: 700; + line-height: size_unit(4); + margin-bottom: 0; + padding: 0 size_unit(3) size_unit(4) size_unit(3); + .subItem { + color: var(--text-color-secondary); + font-size: var(--bodyLarge); + font-weight: 600; + line-height: size_unit(3); + margin-bottom: -#{size_unit(1)}; + margin-left: size_unit(1); + } +} + +.title { + display: flex; + flex-direction: row; + @include text-subHeading-bold; + color: var(--text-color-primary); + gap: size_unit(1); + align-items: baseline; + margin-bottom: size_unit(2); + + h1 { + margin: 0; + } +} + +.sideText { + @include text-bodyLarge-semi-bold; + color: var(--text-color-secondary) !important; + line-height: 32px; +} + +.subHeader { + color: var(--text-color-primary); + font-size: var(--subHeading); + font-weight: 700; + line-height: size_unit(4); + margin-bottom: size_unit(2); +} + +.sectionTilte { + padding-left: size_unit(3); +} diff --git a/apps/browser-extension-wallet/src/features/delegation/components/DelegationLayout.tsx b/apps/browser-extension-wallet/src/features/delegation/components/DelegationLayout.tsx new file mode 100644 index 0000000000..6032d27ce4 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/delegation/components/DelegationLayout.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import isNumber from 'lodash/isNumber'; +import { useTranslation } from 'react-i18next'; +import { Skeleton, Typography } from 'antd'; +import { StakePoolSearch, StakePoolSearchProps, Wallet } from '@lace/cardano'; +import { StakeFundsBanner } from '@views/browser/features/staking/components/StakeFundsBanner'; +import { FundWalletBanner } from '@src/views/browser-view/components'; +import { StakingInfo } from '@views/browser/features/staking/components/StakingInfo'; +import { ContentLayout } from '@src/components/Layout'; +import { ExpandViewBanner } from './ExpandViewBanner'; +import styles from './DelegationLayout.module.scss'; +import { SectionTitle } from '@components/Layout/SectionTitle'; +import { useWalletStore } from '@src/stores'; +import { CoinId } from '@src/types'; + +const { Text } = Typography; + +export interface StakePool { + id: string; + theme?: string; + name?: string; + ticker?: string; + logo?: string; + pledgeMet?: boolean; + retired?: boolean; + onClick?: () => unknown; +} + +export type DelegationLayoutProps = { + searchValue: string; + searchedPools: StakePoolSearchProps['pools']; + currentStakePool?: StakePool; + handleSearchChange: (val: string) => unknown; + coinBalance: number; + handleAddFunds: () => unknown; + showAddFunds?: boolean; + isLoading?: boolean; + isSearching?: boolean; + hasNoFunds?: boolean; + isDelegating?: boolean; + canDelegate?: boolean; + walletAddress: Wallet.Cardano.PaymentAddress; + fiat?: number; + totalRewards: string; + lastReward: string; + onStakePoolSelect: () => void; + onStakePoolClick?: (id: string) => void; + cardanoCoin: CoinId; +}; + +export const DelegationLayout = ({ + searchedPools, + handleSearchChange, + coinBalance, + currentStakePool, + fiat, + isLoading, + isSearching, + hasNoFunds, + isDelegating, + canDelegate, + walletAddress, + totalRewards, + lastReward, + onStakePoolSelect, + onStakePoolClick, + cardanoCoin +}: DelegationLayoutProps): React.ReactElement => { + const { t } = useTranslation(); + const totalResultCount = useWalletStore(({ stakePoolSearchResults }) => stakePoolSearchResults?.totalResultCount); + const showExpandView = hasNoFunds || (!hasNoFunds && !isDelegating) || isDelegating; + + const stakePoolSearchTranslations = { + gettingSaturated: t('cardano.stakePoolSearch.gettingSaturated'), + saturated: t('cardano.stakePoolSearch.saturated'), + overSaturation: t('cardano.stakePoolSearch.overSaturation'), + staking: t('cardano.stakePoolSearch.staking'), + searchPlaceholder: t('cardano.stakePoolSearch.searchPlaceholder') + }; + + return ( + } + isLoading={isLoading} + > +
+ +
+ {canDelegate && } + + {hasNoFunds && ( + + )} + {isDelegating && ( + + )} +
+
+

{t('staking.stakePools.sectionTitle')}

+ + ({totalResultCount || 0}) + +
+ + +
+ {showExpandView && } +
+
+
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/features/delegation/components/ExpandViewBanner/ExpandViewBanner.module.scss b/apps/browser-extension-wallet/src/features/delegation/components/ExpandViewBanner/ExpandViewBanner.module.scss new file mode 100644 index 0000000000..4f97860ff3 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/delegation/components/ExpandViewBanner/ExpandViewBanner.module.scss @@ -0,0 +1,39 @@ +@import '../../../../../../../packages/common/src/ui/styles/theme.scss'; + +.wrapper { + background: var(--bg-color-container, #ffffff); + box-shadow: var(--dark-mode-shadow-setup-box, var(--shadow-setup-box)); + border-radius: size_unit(2); +} + +.container { + display: flex; + flex-direction: column; + padding: size_unit(4); +} + +.img { + margin-bottom: -#{size_unit(3)}; +} + +.title { + font-weight: 500; + font-size: var(--body); + line-height: size_unit(3); + color: var(--text-color-secondary); +} +.description { + color: var(--text-color-primary); + font-size: var(--bodyLarge); + line-height: size_unit(3); + font-weight: 500; + margin-top: size_unit(0.5); + margin-bottom: size_unit(4); +} + +.button { + background-color: #252525 !important; + .icon { + font-size: var(--subHeading); + } +} diff --git a/apps/browser-extension-wallet/src/features/delegation/components/ExpandViewBanner/ExpandViewBanner.tsx b/apps/browser-extension-wallet/src/features/delegation/components/ExpandViewBanner/ExpandViewBanner.tsx new file mode 100644 index 0000000000..2400c0a8e8 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/delegation/components/ExpandViewBanner/ExpandViewBanner.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import Icon from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@lace/common'; +import { BrowserViewSections } from '@src/lib/scripts/types'; +import ExpandIcon from '../../../../assets/icons/expand-gradient.component.svg'; +import styles from './ExpandViewBanner.module.scss'; +import { useBackgroundServiceAPIContext } from '@providers/BackgroundServiceAPI'; + +export const ExpandViewBanner = (): React.ReactElement => { + const { t } = useTranslation(); + const backgroundServices = useBackgroundServiceAPIContext(); + + return ( +
+
+
{t('staking.expandView.title')}
+
{t('staking.expandView.description')}
+ +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/features/delegation/components/ExpandViewBanner/index.ts b/apps/browser-extension-wallet/src/features/delegation/components/ExpandViewBanner/index.ts new file mode 100644 index 0000000000..0021c9be13 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/delegation/components/ExpandViewBanner/index.ts @@ -0,0 +1 @@ +export * from './ExpandViewBanner'; diff --git a/apps/browser-extension-wallet/src/features/delegation/components/index.ts b/apps/browser-extension-wallet/src/features/delegation/components/index.ts new file mode 100644 index 0000000000..2cda11a924 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/delegation/components/index.ts @@ -0,0 +1,2 @@ +export * from './DelegationContainer'; +export * from './DelegationLayout'; diff --git a/apps/browser-extension-wallet/src/features/delegation/index.ts b/apps/browser-extension-wallet/src/features/delegation/index.ts new file mode 100644 index 0000000000..07635cbbc8 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/delegation/index.ts @@ -0,0 +1 @@ +export * from './components'; diff --git a/apps/browser-extension-wallet/src/features/delegation/stores/__tests__/createDelegationStore.test.ts b/apps/browser-extension-wallet/src/features/delegation/stores/__tests__/createDelegationStore.test.ts new file mode 100644 index 0000000000..ecee0bcea3 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/delegation/stores/__tests__/createDelegationStore.test.ts @@ -0,0 +1,46 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { stakePoolDetailsSelector, useDelegationStore } from '../createDelegationStore'; +import { + cardanoStakePoolMock, + TransactionBuildMock, + cardanoStakePoolSelectedDetails +} from '../../../../utils/mocks/test-helpers'; +import { DelegationStore } from '../../types'; + +describe('Testing useDelegationStore hook', () => { + test('should return delegation store', () => { + const { result } = renderHook(() => useDelegationStore()); + expect(result.current.setDelegationBuiltTx).toBeDefined(); + expect(result.current.setSelectedStakePool).toBeDefined(); + expect(result.current.delegationBuiltTx).not.toBeDefined(); + expect(result.current.selectedStakePool).not.toBeDefined(); + }); + + test('should return update state of selectedStakepool', () => { + const { result, waitForValueToChange } = renderHook(() => useDelegationStore()); + expect(result.current.setSelectedStakePool).toBeDefined(); + + act(() => { + result.current.setSelectedStakePool(cardanoStakePoolMock.pageResults[0]); + }); + waitForValueToChange(() => result.current.selectedStakePool); + expect(result.current.selectedStakePool).toEqual(cardanoStakePoolMock.pageResults[0]); + }); + + test('should return update state of delegationBuiltTx', () => { + const { result, waitForValueToChange } = renderHook(() => useDelegationStore()); + expect(result.current.setDelegationBuiltTx).toBeDefined(); + + act(() => { + result.current.setDelegationBuiltTx(TransactionBuildMock); + }); + waitForValueToChange(() => result.current.delegationBuiltTx); + expect(result.current.delegationBuiltTx).toEqual(TransactionBuildMock); + }); + + test('should return proper data form stakePoolDetailsSelector', () => { + expect( + stakePoolDetailsSelector({ selectedStakePool: cardanoStakePoolMock.pageResults[0] } as unknown as DelegationStore) + ).toEqual(cardanoStakePoolSelectedDetails); + }); +}); diff --git a/apps/browser-extension-wallet/src/features/delegation/stores/createDelegationStore.ts b/apps/browser-extension-wallet/src/features/delegation/stores/createDelegationStore.ts new file mode 100644 index 0000000000..b3b1df3925 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/delegation/stores/createDelegationStore.ts @@ -0,0 +1,59 @@ +/* eslint-disable no-magic-numbers */ +import create, { StateSelector } from 'zustand'; +import { Wallet } from '@lace/cardano'; +import { getRandomIcon } from '@src/utils/get-random-icon'; +import { CardanoStakePool, CardanoTxBuild } from '../../../types'; +import { DelegationStore, stakePoolDetailsSelectorProps } from '../types'; +import { formatNumber, formatPercentages } from '@src/utils/format-number'; + +export const stakePoolDetailsSelector: StateSelector = ({ + selectedStakePool +}: // eslint-disable-next-line consistent-return +DelegationStore): stakePoolDetailsSelectorProps => { + if (selectedStakePool) { + const { + id, + cost, + hexId, + metadata: { description = '', name = '', ticker = '', homepage, ext } = {}, + metrics: { apy, delegators, stake, saturation }, + margin, + owners, + logo, + status + } = selectedStakePool; + const calcMargin = margin ? `${formatPercentages(margin.numerator / margin.denominator)}` : '-'; + + return { + // TODO: a lot of this is repeated in `stakePoolTransformer`. Have only one place to parse this info + delegators: delegators || '-', + description, + hexId: hexId.toString(), + id: id.toString(), + logo: logo ?? getRandomIcon({ id: id.toString(), size: 30 }), + margin: calcMargin, + name, + owners: owners ? owners.map((owner: Wallet.Cardano.RewardAccount) => owner.toString()) : [], + saturation: saturation && formatPercentages(saturation), + stake: stake?.active + ? formatNumber(Wallet.util.lovelacesToAdaString(stake?.active?.toString())) + : { number: '-' }, + ticker, + status, + apy: apy && formatPercentages(apy), + fee: Wallet.util.lovelacesToAdaString(cost.toString()), + contact: { + primary: homepage, + ...ext?.pool.contact + } + }; + } +}; + +/** + * returns a hook to access delegation store states and setters + */ +export const useDelegationStore = create((set) => ({ + setSelectedStakePool: (pool: CardanoStakePool) => set({ selectedStakePool: pool }), + setDelegationBuiltTx: (tx?: CardanoTxBuild) => set({ delegationBuiltTx: tx }) +})); diff --git a/apps/browser-extension-wallet/src/features/delegation/stores/index.ts b/apps/browser-extension-wallet/src/features/delegation/stores/index.ts new file mode 100644 index 0000000000..602c29fc41 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/delegation/stores/index.ts @@ -0,0 +1 @@ +export * from './createDelegationStore'; diff --git a/apps/browser-extension-wallet/src/features/delegation/types/index.ts b/apps/browser-extension-wallet/src/features/delegation/types/index.ts new file mode 100644 index 0000000000..c31f3042e5 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/delegation/types/index.ts @@ -0,0 +1,46 @@ +import { Wallet } from '@lace/cardano'; +import { CardanoStakePool, CardanoTxBuild } from '../../../types'; + +export interface DelegationStore { + selectedStakePool?: CardanoStakePool & { logo?: string }; + delegationBuiltTx?: CardanoTxBuild; + setSelectedStakePool: (pool: CardanoStakePool & { logo?: string }) => void; + setDelegationBuiltTx: (tx?: CardanoTxBuild) => void; +} + +export interface StakePool { + id: string; + hexId: string; + pledge: string; + margin: string; + cost: string; + owners: string[]; + name?: string; + description?: string; + ticker?: string; + logo?: string; + retired?: boolean; + apy?: number | string; + size?: string; + saturation?: number | string; + fee?: number | string; + isStakingPool?: boolean; +} + +export type stakePoolDetailsSelectorProps = { + delegators: number | string; + description: string; + hexId: string; + id: string; + logo?: string; + margin: number | string; + name: string; + owners: string[]; + saturation: number | string; + stake: { number: string; unit?: string }; + ticker: string; + apy: number | string; + status: Wallet.Cardano.StakePool['status']; + fee: number | string; + contact: Wallet.Cardano.PoolContactData; +}; diff --git a/apps/browser-extension-wallet/src/features/nfts/components/NftDetail.tsx b/apps/browser-extension-wallet/src/features/nfts/components/NftDetail.tsx new file mode 100644 index 0000000000..c86c8ef439 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/nfts/components/NftDetail.tsx @@ -0,0 +1,79 @@ +import { useObservable, useRedirection } from '@hooks'; +import { walletRoutePaths } from '@routes'; +import React, { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import styles from './Nfts.module.scss'; +import { Button, Drawer, DrawerNavigation } from '@lace/common'; +import { useWalletStore } from '@src/stores'; +import { nftDetailSelector } from '@src/views/browser-view/features/nfts/selectors'; +import { NftDetail as NftDetailView } from '@lace/core'; +import { Wallet } from '@lace/cardano'; +import { useTranslation } from 'react-i18next'; +import { useOutputInitialState } from '@src/views/browser-view/features/send-transaction'; +import { DEFAULT_WALLET_BALANCE, SEND_NFT_DEFAULT_AMOUNT } from '@src/utils/constants'; +import { + AnalyticsEventActions, + AnalyticsEventCategories, + AnalyticsEventNames +} from '@providers/AnalyticsProvider/analyticsTracker'; +import { useAnalyticsContext } from '@providers'; +import { buttonIds } from '@hooks/useEnterKeyPress'; + +export const NftDetail = (): React.ReactElement => { + const { inMemoryWallet } = useWalletStore(); + const { t } = useTranslation(); + const analytics = useAnalyticsContext(); + + const [redirectToNfts] = useRedirection(walletRoutePaths.nfts); + const [redirectToSend] = useRedirection<{ params: { id?: string } }>(walletRoutePaths.send); + const { id } = useParams<{ id: string }>(); + const assetsInfo = useObservable(inMemoryWallet.assetInfo$); + const setSendInitialState = useOutputInitialState(); + + const assetId = Wallet.Cardano.AssetId(id); + const assetInfo = assetsInfo?.get(assetId); + const assetsBalance = useObservable(inMemoryWallet.balance.utxo.total$, DEFAULT_WALLET_BALANCE.utxo.total$); + const bigintBalance = assetsBalance?.assets?.get(assetId) || BigInt(1); + + const amount = useMemo(() => Wallet.util.calculateAssetBalance(bigintBalance, assetInfo), [assetInfo, bigintBalance]); + + const nftDetailTranslation = { + tokenInformation: t('core.nftDetail.tokenInformation'), + attributes: t('core.nftDetail.attributes') + }; + + const handleOpenSend = () => { + analytics.sendEvent({ + category: AnalyticsEventCategories.VIEW_NFT, + action: AnalyticsEventActions.CLICK_EVENT, + name: AnalyticsEventNames.ViewNFTs.SEND_NFT_POPUP + }); + setSendInitialState(id, SEND_NFT_DEFAULT_AMOUNT); + redirectToSend({ params: { id } }); + }; + + return ( + redirectToNfts()} />} + footer={ +
+ +
+ } + > + {assetInfo && ( + {assetInfo.nftMetadata?.name ?? assetInfo.fingerprint}} + /> + )} +
+ ); +}; diff --git a/apps/browser-extension-wallet/src/features/nfts/components/Nfts.module.scss b/apps/browser-extension-wallet/src/features/nfts/components/Nfts.module.scss new file mode 100644 index 0000000000..28c3f61328 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/nfts/components/Nfts.module.scss @@ -0,0 +1,92 @@ +@import '../../../styles/utils/functions.scss'; +@import '../../../../../../packages/common/src/ui/styles/theme.scss'; + +.nftsLayout { + overflow: hidden; + margin-top: size_unit(1); + margin-right: -#{size_unit(1)}; + width: 100%; +} + +.nfts { + display: flex; + flex-direction: column; + .content { + flex: 1; + padding-bottom:size_unit(2); + display: flex; + flex-direction: column; + } + .content::-webkit-scrollbar { + width: 0; + scrollbar-width: thin; + } + + .content::-webkit-scrollbar-track { + width: 6px; + background-color: var(--color-white, #ffffff); + border-radius: 0px 0px 0px size_unit(2); + } + + .content::-webkit-scrollbar-thumb { + width: 6px; + background-color: var(--light-mode-light-grey-plus); + border-radius: size_unit(2); + } +} + +.header { + align-items: baseline; + color: var(--text-color-black); + display: flex; + font-size: var(--heading); + font-weight: 700; + line-height: size_unit(4); + margin-bottom: 0; + padding: 0 25px size_unit(4) 25px; + .subItem { + color: var(--text-color-secondary); + font-size: var(--bodyLarge); + font-weight: 600; + line-height: size_unit(3); + margin-bottom: -#{size_unit(1)}; + margin-left: size_unit(1); + } +} + +.footer { + button.sendBtn { + width: 100%; + font-weight: 600 !important; + } +} + +.secondaryTitle { + font-size: var(--heading); + font-weight: 700; + line-height: size_unit(5); + text-align: left; + margin-top: size_unit(1); + color: var(--text-color-primary); + text-align: left; + width: 100%; +} + +.sectionTitle { + display: flex; + justify-content: space-between; + margin-right: size_unit(3); + margin-bottom: 18px; + .title { + margin-bottom: 0 !important; + } + .newFolderBtn { + min-width: size_unit(6) !important; + min-height: size_unit(6) !important; + margin: -#{size_init(1)} 0; + padding: 0; + .newFolderIcon { + font-size: var(--bodyLarge); + } + } +} diff --git a/apps/browser-extension-wallet/src/features/nfts/components/Nfts.tsx b/apps/browser-extension-wallet/src/features/nfts/components/Nfts.tsx new file mode 100644 index 0000000000..66412994f8 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/nfts/components/Nfts.tsx @@ -0,0 +1,211 @@ +/* eslint-disable unicorn/no-useless-undefined */ +import { useObservable, useRedirection } from '@hooks'; +import { useWalletStore } from '@src/stores'; +import { Button } from '@lace/common'; +import { DEFAULT_WALLET_BALANCE } from '@src/utils/constants'; +import flatten from 'lodash/flatten'; +import isNil from 'lodash/isNil'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './Nfts.module.scss'; +import { NftFolderItemProps, NftItemProps, NftList, NftListProps, NftsItemsTypes } from '@lace/core'; +import { ContentLayout } from '@src/components/Layout'; +import { FundWalletBanner } from '@src/views/browser-view/components'; +import { walletRoutePaths } from '@routes'; +import { getTokenList } from '@src/utils/get-token-list'; +import { + AnalyticsEventActions, + AnalyticsEventCategories, + AnalyticsEventNames +} from '@providers/AnalyticsProvider/analyticsTracker'; +import FolderIcon from '@assets/icons/new-folder-icon.component.svg'; +import { SectionTitle } from '@components/Layout/SectionTitle'; +import { NFTFolderDrawer } from '@src/views/browser-view/features/nfts/components/CreateFolder/CreateFolderDrawer'; +import { NftFoldersRecordParams, useNftsFoldersContext, withNftsFoldersContext } from '../context'; +import { RenameFolderDrawer } from '@views/browser/features/nfts/components/RenameFolderDrawer'; +import { RenameFolderType } from '@views/browser/features/nfts'; +import { NftFolderConfirmationModal } from '@views/browser/features/nfts/components/NftFolderConfirmationModal'; +import RemoveFolderIcon from '@assets/icons/remove-folder.component.svg'; +import { useAnalyticsContext, useCurrencyStore } from '@providers'; + +export const Nfts = withNftsFoldersContext((): React.ReactElement => { + const [redirectToNftDetail] = useRedirection<{ params: { id: string } }>(walletRoutePaths.nftDetail); + const [isCreateFolderDrawerOpen, setIsCreateFolderDrawerOpen] = useState(false); + const { environmentName } = useWalletStore(); + + const [selectedFolderId, setSelectedFolderId] = useState(); + const { walletInfo, inMemoryWallet } = useWalletStore(); + const { t } = useTranslation(); + const assetsInfo = useObservable(inMemoryWallet.assetInfo$); + const assetsBalance = useObservable(inMemoryWallet.balance.utxo.total$, DEFAULT_WALLET_BALANCE.utxo.total$); + const analytics = useAnalyticsContext(); + const { fiatCurrency } = useCurrencyStore(); + const [folderToRename, setFolderToRename] = useState(); + const [folderToDelete, setFolderToDelete] = useState(); + const [isRenameFolderDrawerOpen, setIsRenameFolderDrawerOpen] = useState(false); + const [isDeleteFolderModalOpen, setIsDeleteFolderModalOpen] = useState(false); + const { + utils: { deleteRecord } + } = useNftsFoldersContext(); + + const onSelectNft = useCallback( + (nft) => { + analytics.sendEvent({ + category: AnalyticsEventCategories.VIEW_NFT, + action: AnalyticsEventActions.CLICK_EVENT, + name: AnalyticsEventNames.ViewNFTs.VIEW_NFT_DETAILS_POPUP + }); + redirectToNftDetail({ params: { id: nft.assetId.toString() } }); + }, + [analytics, redirectToNftDetail] + ); + + const nfts: NftItemProps[] = useMemo(() => { + const { nftList } = getTokenList({ assetsInfo, balance: assetsBalance?.assets, environmentName, fiatCurrency }); + return nftList.map((nft) => ({ + ...nft, + type: NftsItemsTypes.NFT, + onClick: () => onSelectNft(nft) + })); + }, [assetsBalance?.assets, assetsInfo, environmentName, fiatCurrency, onSelectNft]); + + const renderContextMenu = useCallback( + (id: number, folderName: string) => + [ + { + label: t('browserView.nfts.contextMenu.rename'), + onClick: () => { + setIsRenameFolderDrawerOpen(true); + setFolderToRename({ id, name: folderName }); + } + }, + { + label: t('browserView.nfts.contextMenu.delete'), + onClick: () => { + setFolderToDelete(id); + setIsDeleteFolderModalOpen(true); + } + } + ] || [], + [t] + ); + + const { list: nftFolders } = useNftsFoldersContext(); + const folders: NftFolderItemProps[] = useMemo( + () => + nftFolders?.map(({ name, assets, id }: NftFoldersRecordParams) => ({ + id, + name, + type: NftsItemsTypes.FOLDER, + nfts: assets + .map((nftId: string) => nfts.find(({ assetId }) => assetId.toString() === nftId)) + .filter((_nfts) => !!_nfts), + contextMenuItems: renderContextMenu(id, name), + onClick: () => { + setSelectedFolderId(id); + setIsCreateFolderDrawerOpen(true); + } + })) || [], + [nftFolders, nfts, renderContextMenu] + ); + + const selectedFolder = useMemo(() => folders.find(({ id }) => selectedFolderId === id), [selectedFolderId, folders]); + + const usedNftsIds = flatten(nftFolders?.map(({ assets }: NftFoldersRecordParams) => assets)); + const nftsNotInFolders = nfts.filter(({ assetId }) => !usedNftsIds.includes(assetId)); + const items: NftListProps['items'] = [...folders, ...nftsNotInFolders]; + + const isLoadingFirstTime = isNil(assetsBalance) || isNil(nftFolders); + + const onDeleteFolderConfirm = () => { + setIsDeleteFolderModalOpen(false); + deleteRecord(folderToDelete, { + text: t('browserView.nfts.deleteFolderSuccess'), + icon: RemoveFolderIcon + }); + }; + const onCloseFolderDrawer = useCallback(() => { + setIsCreateFolderDrawerOpen(false); + setSelectedFolderId(undefined); + }, []); + + return ( + <> + + + {nfts.length > 0 && process.env.USE_NFT_FOLDERS === 'true' && ( + + )} +
+ } + isLoading={isLoadingFirstTime} + mainClassName={styles.nftsLayout} + > +
+
+ {items.length > 0 ? ( + + ) : ( + + )} +
+
+ + setIsRenameFolderDrawerOpen(false)} + isPopupView + /> + + setIsDeleteFolderModalOpen(false)} + visible={isDeleteFolderModalOpen} + title={t('browserView.nfts.deleteFolderModal.header')} + description={ + <> +
{t('browserView.nfts.deleteFolderModal.description1')}
+
{t('browserView.nfts.deleteFolderModal.description2')}
+ + } + actions={[ + { + body: t('browserView.nfts.deleteFolderModal.cancel'), + dataTestId: 'delete-folder-modal-cancel', + color: 'secondary', + onClick: () => setIsDeleteFolderModalOpen(false) + }, + { + dataTestId: 'delete-folder-modal-confirm', + onClick: onDeleteFolderConfirm, + body: t('browserView.nfts.deleteFolderModal.confirm') + } + ]} + /> + + ); +}); diff --git a/apps/browser-extension-wallet/src/features/nfts/components/index.ts b/apps/browser-extension-wallet/src/features/nfts/components/index.ts new file mode 100644 index 0000000000..5b1882f73e --- /dev/null +++ b/apps/browser-extension-wallet/src/features/nfts/components/index.ts @@ -0,0 +1,2 @@ +export * from './Nfts'; +export * from './NftDetail'; diff --git a/apps/browser-extension-wallet/src/features/nfts/context/NftsFoldersProvider.tsx b/apps/browser-extension-wallet/src/features/nfts/context/NftsFoldersProvider.tsx new file mode 100644 index 0000000000..1409164fe6 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/nfts/context/NftsFoldersProvider.tsx @@ -0,0 +1,38 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable react/no-multi-comp */ +import React, { useMemo } from 'react'; +import { NftsFoldersContext } from './context'; +import { nftFoldersQueries, NftFoldersSchema, nftFoldersSchema, useDbState } from '@src/lib/storage'; +import { useWalletStore } from '@src/stores'; + +interface NftsFoldersProviderProps { + children: React.ReactNode; + initialState?: Array; +} + +export type NftFoldersRecordParams = Pick; + +export const NftsFoldersProvider = ({ children, initialState }: NftsFoldersProviderProps): React.ReactElement => { + const { environmentName } = useWalletStore(); + const queries = useMemo(() => nftFoldersQueries(environmentName), [environmentName]); + + return ( + (initialState, nftFoldersSchema, queries)} + > + {children} + + ); +}; + +type WithNftsFoldersContext = ( + element: React.JSXElementConstructor, + initialState?: Array +) => (props?: TProps) => React.ReactElement; + +export const withNftsFoldersContext: WithNftsFoldersContext = (Component, initialState) => (props) => + ( + + + + ); diff --git a/apps/browser-extension-wallet/src/features/nfts/context/__test__/context.test.tsx b/apps/browser-extension-wallet/src/features/nfts/context/__test__/context.test.tsx new file mode 100644 index 0000000000..548b7487ab --- /dev/null +++ b/apps/browser-extension-wallet/src/features/nfts/context/__test__/context.test.tsx @@ -0,0 +1,110 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-magic-numbers */ +import React, { FunctionComponent } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useNftsFoldersContext } from '../context'; +import { NftsFoldersProvider } from '../NftsFoldersProvider'; +import { WalletDatabase, NftFoldersSchema, nftFoldersSchema } from '@src/lib/storage'; +import { DatabaseProvider } from '@src/providers/DatabaseProvider'; +import { StoreProvider } from '@src/stores'; +import create from 'zustand'; +import { AppSettingsProvider } from '@providers'; + +jest.mock('../NftsFoldersProvider', () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...jest.requireActual('../NftsFoldersProvider'), + withNftsFoldersContext: jest.fn() +})); + +const makeDbContextWrapper = + (dbIntance: WalletDatabase): FunctionComponent => + ({ children }: { children?: React.ReactNode }) => + ( + + ({ environmentName: 'Preprod' } as any))}> + + {children} + + + + ); + +describe('testing useNftsFoldersState', () => { + let db: WalletDatabase; + const mockFolderList: NftFoldersSchema[] = Array.from({ length: 15 }, (_v, i) => ({ + id: i + 1, + assets: [`asset${i + 1}`], + name: `test folder ${i + 1}`, + network: 'Preprod' + })); + + beforeEach(async () => { + db = new WalletDatabase(); + db.getConnection(nftFoldersSchema).bulkAdd(mockFolderList); + }); + + afterEach(() => db.delete()); + + test('should return state and utils', async () => { + const { result, waitForNextUpdate } = renderHook(() => useNftsFoldersContext(), { + wrapper: makeDbContextWrapper(db) + }); + expect(result.current.utils.deleteRecord).toBeDefined(); + expect(result.current.utils.saveRecord).toBeDefined(); + expect(result.current.utils.extendLimit).toBeDefined(); + + await waitForNextUpdate(); + + expect(result.current.list.length).toBe(10); + expect(result.current.count).toBe(15); + }); + + test('should add new folder and extend limit', async () => { + const { result, waitForNextUpdate } = renderHook(() => useNftsFoldersContext(), { + wrapper: makeDbContextWrapper(db) + }); + + await waitForNextUpdate(); + + result.current.utils.saveRecord({ assets: ['folder_test16'], name: 'test folder 16' }); + + await waitForNextUpdate(); + + result.current.utils.extendLimit(); + + await waitForNextUpdate(); + + expect(result.current.list).toContainEqual({ + assets: ['folder_test16'], + id: 16, + name: 'test folder 16', + network: 'Preprod' + }); + expect(result.current.list.length).toBe(16); + expect(result.current.count).toBe(16); + }); + + test('should delete folder', async () => { + const { result, waitForNextUpdate } = renderHook(() => useNftsFoldersContext(), { + wrapper: makeDbContextWrapper(db) + }); + + await waitForNextUpdate(); + + result.current.utils.deleteRecord(1); + + await waitForNextUpdate(); + + result.current.utils.extendLimit(); + + await waitForNextUpdate(); + + expect(result.current.list).not.toContainEqual({ + assets: ['asset_test1'], + name: 'test folder', + id: 1 + }); + expect(result.current.list.length).toBe(14); + expect(result.current.count).toBe(14); + }); +}); diff --git a/apps/browser-extension-wallet/src/features/nfts/context/context.ts b/apps/browser-extension-wallet/src/features/nfts/context/context.ts new file mode 100644 index 0000000000..c99ad12404 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/nfts/context/context.ts @@ -0,0 +1,11 @@ +import { createContext, useContext } from 'react'; +import { NftFoldersSchema, useDbState, useDbStateValue } from '../../../lib/storage'; + +// eslint-disable-next-line unicorn/no-null +export const NftsFoldersContext = createContext | null>(null); + +export const useNftsFoldersContext = (): ReturnType => { + const nftsFlders = useContext(NftsFoldersContext); + if (nftsFlders === null) throw new Error('NftsFoldersContext is not defined.'); + return nftsFlders; +}; diff --git a/apps/browser-extension-wallet/src/features/nfts/context/index.ts b/apps/browser-extension-wallet/src/features/nfts/context/index.ts new file mode 100644 index 0000000000..f883f0a94e --- /dev/null +++ b/apps/browser-extension-wallet/src/features/nfts/context/index.ts @@ -0,0 +1,2 @@ +export * from './context'; +export * from './NftsFoldersProvider'; diff --git a/apps/browser-extension-wallet/src/features/nfts/index.ts b/apps/browser-extension-wallet/src/features/nfts/index.ts new file mode 100644 index 0000000000..07635cbbc8 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/nfts/index.ts @@ -0,0 +1 @@ +export * from './components'; diff --git a/apps/browser-extension-wallet/src/features/receive-info/components/ReceiveInfo.module.scss b/apps/browser-extension-wallet/src/features/receive-info/components/ReceiveInfo.module.scss new file mode 100644 index 0000000000..6919475ecd --- /dev/null +++ b/apps/browser-extension-wallet/src/features/receive-info/components/ReceiveInfo.module.scss @@ -0,0 +1,17 @@ +@import '../../../styles/index'; +@import '../../../../../../packages/common/src/ui/styles/theme.scss'; + +.container { + position: relative; + padding: size_unit(5) 0 0; +} + +.title { + color: var(--text-color-primary); + @include text-heading; +} + +.subtitle { + color: var(--text-color-secondary); + @include text-bodyLarge-medium; +} diff --git a/apps/browser-extension-wallet/src/features/receive-info/components/ReceiveInfo.tsx b/apps/browser-extension-wallet/src/features/receive-info/components/ReceiveInfo.tsx new file mode 100644 index 0000000000..131a765b81 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/receive-info/components/ReceiveInfo.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { InfoWallet } from '@lace/core'; +import { Drawer, DrawerHeader, DrawerNavigation } from '@lace/common'; +import { useTranslation } from 'react-i18next'; +import styles from './ReceiveInfo.module.scss'; +import { WalletInfo } from '@src/types'; +import { useTheme } from '@providers/ThemeProvider'; +import { getQRCodeOptions } from '@src/utils/qrCodeHelpers'; + +const QR_SIZE = 168; + +export interface ReceiveInfoProps { + /** + * Wallet basic information + */ + wallet: WalletInfo; + /** + * Redirection when pressing the back button + */ + goBack: () => void; +} + +export const ReceiveInfo = ({ wallet, goBack }: ReceiveInfoProps): React.ReactElement => { + const { t } = useTranslation(); + const { theme } = useTheme(); + const handleOnClose = () => goBack(); + + const infoWalletTranslations = { + copy: t('core.infoWallet.copy'), + copiedMessage: t('core.infoWallet.addressCopied') + }; + + return ( + } + navigation={} + popupView + > +
+ getQRCodeOptions(theme, QR_SIZE)} + isPopupView + walletInfo={{ ...wallet, qrData: wallet.address.toString() }} + translations={infoWalletTranslations} + /> +
+
+ ); +}; diff --git a/apps/browser-extension-wallet/src/features/receive-info/components/ReceiveInfoContainer.tsx b/apps/browser-extension-wallet/src/features/receive-info/components/ReceiveInfoContainer.tsx new file mode 100644 index 0000000000..1a1c5c409d --- /dev/null +++ b/apps/browser-extension-wallet/src/features/receive-info/components/ReceiveInfoContainer.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { useRedirection } from '../../../hooks'; +import { walletRoutePaths } from '../../../routes'; +import { useWalletStore } from '../../../stores'; +import { ReceiveInfo } from './ReceiveInfo'; + +export const ReceiveInfoContainer = (): React.ReactElement => { + const [redirectToOverview] = useRedirection(walletRoutePaths.assets); + const { walletInfo } = useWalletStore(); + + return ; +}; diff --git a/apps/browser-extension-wallet/src/features/receive-info/components/__tests__/ReceiveInfo.test.tsx b/apps/browser-extension-wallet/src/features/receive-info/components/__tests__/ReceiveInfo.test.tsx new file mode 100644 index 0000000000..0d3a6b1765 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/receive-info/components/__tests__/ReceiveInfo.test.tsx @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import * as React from 'react'; +import { I18nextProvider } from 'react-i18next'; +import { render } from '@testing-library/react'; +import { ReceiveInfo, ReceiveInfoProps } from '../ReceiveInfo'; +import '@testing-library/jest-dom'; +import i18n from '../../../../lib/i18n'; +import { mockWalletInfoTestnet } from '@src/utils/mocks/test-helpers'; +import { ThemeProvider } from '@providers/ThemeProvider'; + +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +describe('Testing ReceiveInfo component', () => { + window.ResizeObserver = ResizeObserver; + beforeAll(() => { + // qrcode.react lib is printing these warning in development mode only: + // https://github.com/zpao/qrcode.react/issues/134 + jest.spyOn(console, 'warn').mockImplementation(jest.fn()); + jest.spyOn(console, 'error').mockImplementation(jest.fn()); + }); + afterAll(() => { + jest.restoreAllMocks(); + }); + + const props: ReceiveInfoProps = { + wallet: mockWalletInfoTestnet, + goBack: jest.fn() + }; + test('should render a back button and the addressQR screen', async () => { + const { findByTestId } = render( + + + + + + ); + const copyButton = await findByTestId('copy-address-btn'); + const addressQR = await findByTestId('receive-address-qr'); + + expect(addressQR).toBeInTheDocument(); + expect(copyButton).toBeInTheDocument(); + }); +}); diff --git a/apps/browser-extension-wallet/src/features/receive-info/components/index.ts b/apps/browser-extension-wallet/src/features/receive-info/components/index.ts new file mode 100644 index 0000000000..85f3f66165 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/receive-info/components/index.ts @@ -0,0 +1,2 @@ +export * from './ReceiveInfo'; +export * from './ReceiveInfoContainer'; diff --git a/apps/browser-extension-wallet/src/features/send/__tests__/selectors.test.ts b/apps/browser-extension-wallet/src/features/send/__tests__/selectors.test.ts new file mode 100644 index 0000000000..86f140681e --- /dev/null +++ b/apps/browser-extension-wallet/src/features/send/__tests__/selectors.test.ts @@ -0,0 +1,34 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { getSendStoreContext } from '../../../utils/mocks/test-helpers'; +import { cancelModalSelector, sendTransactionSelector } from '../selectors'; +import { useSendStore } from '../stores'; + +describe('Testing cancelModalSelector', () => { + test('should return cancel modal status and setter', () => { + const { result } = renderHook(() => useSendStore(cancelModalSelector), { + wrapper: getSendStoreContext() + }); + + expect(result.current).toHaveProperty('showCancelSendModal'); + expect(result.current).toHaveProperty('setShowCancelSendModal'); + }); +}); + +describe('Testing sendTransactionSelector', () => { + test('should return address, built tx, tx value and setters', () => { + const { result } = renderHook(() => useSendStore(sendTransactionSelector), { + wrapper: getSendStoreContext() + }); + + expect(result.current).toHaveProperty('destinationAddress'); + expect(result.current).toHaveProperty('transactionValue'); + expect(result.current).toHaveProperty('transaction'); + expect(result.current).toHaveProperty('transactionFeeLovelace'); + expect(result.current).toHaveProperty('minimumCoinQuantity'); + expect(result.current).toHaveProperty('setDestinationAddress'); + expect(result.current).toHaveProperty('setTransactionValue'); + expect(result.current).toHaveProperty('setTransaction'); + expect(result.current).toHaveProperty('setTransactionFeeLovelace'); + expect(result.current).toHaveProperty('setMinimumCoinQuantity'); + }); +}); diff --git a/apps/browser-extension-wallet/src/features/send/components/Send.module.scss b/apps/browser-extension-wallet/src/features/send/components/Send.module.scss new file mode 100644 index 0000000000..f6b2c1b261 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/send/components/Send.module.scss @@ -0,0 +1,5 @@ +@import '../../../../../../packages/common/src/ui/styles/theme.scss'; + +.headerTitleContainer { + padding-bottom: size_unit(3); +} \ No newline at end of file diff --git a/apps/browser-extension-wallet/src/features/send/components/Send.tsx b/apps/browser-extension-wallet/src/features/send/components/Send.tsx new file mode 100644 index 0000000000..1ed22844ed --- /dev/null +++ b/apps/browser-extension-wallet/src/features/send/components/Send.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import { + SendTransaction, + useCoinStateSelector, + useAddressState, + useSections, + Sections, + useMultipleSelection +} from '@src/views/browser-view/features/send-transaction'; +import { + Footer, + HeaderNavigation +} from '@src/views/browser-view/features/send-transaction/components/SendTransactionDrawer'; +import { Drawer } from '@lace/common'; +import { BrowserViewSections } from '@lib/scripts/types'; +import { ContinueInBrowserDialog } from '@components/ContinueInBrowserDialog'; +import { useTranslation } from 'react-i18next'; +import { useBackgroundServiceAPIContext } from '@providers/BackgroundServiceAPI'; +import { HeaderTitle } from '@src/views/browser-view/features/send-transaction/components/SendTransactionDrawer/HeaderView'; +import styles from './Send.module.scss'; + +const FIRST_ROW = 'output1'; + +export const Send = (): React.ReactElement => { + const { t } = useTranslation(); + const [isContinueDialogVisible, setIsContinueDialogVisible] = useState(false); + const toggleContinueDialog = () => setIsContinueDialogVisible(!isContinueDialogVisible); + const { + currentSection: { currentSection: section } + } = useSections(); + const backgroundServices = useBackgroundServiceAPIContext(); + const [multipleSelectionAvailable] = useMultipleSelection(); + + const { uiOutputs } = useCoinStateSelector(FIRST_ROW); + const { address } = useAddressState(FIRST_ROW); + + const openTabExtensionSendFlow = (): Promise => { + localStorage.setItem('tempAddress', address); + localStorage.setItem('tempOutputs', JSON.stringify(uiOutputs)); + localStorage.setItem('tempSource', 'hardware-wallet'); + return backgroundServices.handleOpenBrowser({ section: BrowserViewSections.SEND_ADVANCED }); + }; + + const shouldAssetPickerDisplayFooter = multipleSelectionAvailable && section === Sections.ASSET_PICKER; + const shouldDisplayFooter = + ![Sections.ADDRESS_LIST, Sections.ADDRESS_FORM, Sections.ASSET_PICKER].includes(section) || + shouldAssetPickerDisplayFooter; + + return ( + <> + + } + title={ +
+ +
+ } + footer={shouldDisplayFooter ?