diff --git a/.circleci/config.yml b/.circleci/config.yml index af6ce700..f1df1fc0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,14 +6,14 @@ orbs: jobs: test: docker: - - image: 'circleci/node:14' + - image: "circleci/node:14" steps: - checkout - node/install-packages: pkg-manager: yarn - - run: - command: yarn run test - name: Run YARN tests + # - run: + # command: yarn run test + # name: Run YARN tests workflows: test_demo_wallet: diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..9e051592 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.tmp diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index f1cc3ad3..00000000 --- a/.editorconfig +++ /dev/null @@ -1,15 +0,0 @@ -# http://editorconfig.org - -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 2 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -[*.md] -insert_final_newline = false -trim_trailing_whitespace = false diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..2a050c77 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,15 @@ +module.exports = { + extends: ["@stellar/eslint-config"], + rules: { + "no-console": 0, + "import/no-unresolved": "off", + "react/jsx-filename-extension": [1, { extensions: [".tsx", ".jsx"] }], + "react/prop-types": 0, + // note you must disable the base rule as it can report incorrect errors + "no-shadow": "off", + "@typescript-eslint/no-shadow": ["error"], + // note you must disable the base rule as it can report incorrect errors + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error"], + }, +}; diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index dfe07704..00000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -# Auto detect text files and perform LF normalization -* text=auto diff --git a/.gitignore b/.gitignore index 7ff92e2b..d7b8f1bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,25 @@ -dist/ -www/ -loader/ -!src/components/loader +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -*~ -*.sw[mnpcod] -*.log -*.lock -*.tmp -*.tmp.* -log.txt -*.sublime-project -*.sublime-workspace +# dependencies +/node_modules +/.pnp +.pnp.js -.stencil/ -.idea/ -.sass-cache/ -.versions/ -node_modules/ -$RECYCLE.BIN/ +# testing +/coverage +# production +/build + +# misc .DS_Store -Thumbs.db -UserInterfaceState.xcuserstate -.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* -.now +.eslintcache diff --git a/.npmrc b/.npmrc index 43c97e71..b6f27f13 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -package-lock=false +engine-strict=true diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index f704e02f..00000000 --- a/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -src/components.d.ts -readme.md \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index fc5c0886..00000000 --- a/.prettierrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "trailingComma": "es5", - "tabWidth": 2, - "useTabs": false, - "semi": false, - "singleQuote": true, - "bracketSpacing": true, - "arrowParens": "always" -} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 8185c436..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "E2E Test Current File", - "cwd": "${workspaceFolder}", - "program": "${workspaceFolder}/node_modules/.bin/stencil", - "args": ["test", "--e2e", "${relativeFile}"], - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "disableOptimisticBPs": true - }, - { - "type": "node", - "request": "launch", - "name": "Spec Test Current File", - "cwd": "${workspaceFolder}", - "program": "${workspaceFolder}/node_modules/.bin/stencil", - "args": ["test", "--spec", "${relativeFile}"], - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "disableOptimisticBPs": true - } - ] -} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..82f573b9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM ubuntu:20.04 as build + +MAINTAINER SDF Ops Team + +RUN mkdir -p /app +RUN apt-get update && apt-get install -y gnupg1 + +WORKDIR /app + +ARG REACT_APP_AMPLITUDE_KEY + +ENV REACT_APP_AMPLITUDE_KEY $REACT_APP_AMPLITUDE_KEY + +ARG REACT_APP_SENTRY_KEY + +ENV REACT_APP_SENTRY_KEY $REACT_APP_SENTRY_KEY + +RUN apt-get update && apt-get install -y gnupg curl git make apt-transport-https && \ + curl -sSL https://deb.nodesource.com/gpgkey/nodesource.gpg.key | apt-key add - && \ + echo "deb https://deb.nodesource.com/node_14.x focal main" | tee /etc/apt/sources.list.d/nodesource.list && \ + curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ + echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \ + apt-get update && apt-get install -y nodejs yarn && apt-get clean + + +COPY . /app/ +RUN yarn install +RUN yarn build + +FROM nginx:1.17 + +COPY --from=build /app/build/ /usr/share/nginx/html/ +COPY --from=build /app/nginx.conf /etc/nginx/conf.d/default.conf diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..d334feb1 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +# Check if we need to prepend docker commands with sudo +SUDO := $(shell docker version >/dev/null 2>&1 || echo "sudo") + +# If LABEL is not provided set default value +LABEL ?= $(shell git rev-parse --short HEAD)$(and $(shell git status -s),-dirty-$(shell id -u -n)) +# If TAG is not provided set default value +TAG ?= stellar/stellar-demo-wallet:$(LABEL) +# https://github.com/opencontainers/image-spec/blob/master/annotations.md +BUILD_DATE := $(shell date --utc --rfc-3339=seconds) + +docker-build: + $(SUDO) docker build --pull --label org.opencontainers.image.created="$(BUILD_DATE)" \ + --build-arg REACT_APP_AMPLITUDE_KEY=$(AMPLITUDE_KEY) --build-arg REACT_APP_SENTRY_KEY=$(SENTRY_KEY) -t $(TAG) . + +docker-push: + $(SUDO) docker push $(TAG) diff --git a/readme.md b/README.md similarity index 56% rename from readme.md rename to README.md index 2f4e948f..3456a13c 100644 --- a/readme.md +++ b/README.md @@ -1,8 +1,17 @@ # Stellar Demo Wallet -This Stellar Demo Wallet app will soon be the defacto application to use when testing anchor services interactively. If you would like to automate testing of your anchor service, check out the SDF's [anchor validation suite](https://github.com/stellar/transfer-server-validator) viewable at [anchor-validator.stellar.org](https://anchor-validator.stellar.org/). - -This project was originally created for the [Build a Stellar Wallet](https://developers.stellar.org/docs/building-apps/) tutorial series. ([That repo has since moved over here](https://github.com/stellar/docs-wallet)). If you want to use part or all of the project to kickstart your own wallet, feel free to clone or copy any pieces which may be helpful. +This Stellar Demo Wallet app will soon be the defacto application to use when +testing anchor services interactively. If you would live to automate testing of +your anchor service, check out the SDF's +[anchor validation suite](https://github.com/stellar/transfer-server-validator) +viewable at [anchor-validator.stellar.org](anchor-validator.stellar.org). + +This project was originally created for the +[Build a Stellar Wallet](https://developers.stellar.org/docs/building-apps/) +tutorial series. +([That repo has since moved over here](https://github.com/stellar/docs-wallet)). +If you want to use parts or all of the project to kickstart your own wallet, +feel free to clone or copy any pieces which may be helpful. ## Getting Started @@ -37,7 +46,7 @@ yarn build - [ ] Implement SEP-31 support - [ ] Implement SEP-6 support -### Helpful links: +### Helpful links - [https://www.stellar.org/developers](https://www.stellar.org/developers) - [https://stellar.github.io/js-stellar-sdk/](https://stellar.github.io/js-stellar-sdk/) diff --git a/architecture.md b/architecture.md new file mode 100644 index 00000000..93048224 --- /dev/null +++ b/architecture.md @@ -0,0 +1,81 @@ +# Architecture + +This document describes the high-level architecture and file structure of the +Demo Wallet. + +## Tech stack + +- [TypeScript](https://www.typescriptlang.org/) +- [React](https://reactjs.org/) for UI +- [React Router](https://reactrouter.com/web/guides/quick-start) for routing +- [Sass](https://sass-lang.com/) for CSS styling +- [Stellar Design System](https://github.com/stellar/stellar-design-system) for + re-usable components and styles +- [Redux Toolkit](https://redux-toolkit.js.org/) for global state management +- [Yarn](https://yarnpkg.com/) for package management + +## File structure + +### `index.tsx` + +Root file of the project + +### `App.tsx` + +Top level file/entry point of the app + +### `App.scss` + +Global file of styles + +### `/assets` + +Images and SVGs (icons) + +### `/components` + +Building blocks of the UI. Larger components go into their own directory. + +If a component requires its own styles (and it doesn't belong in the global +`App.scss` file), it will have its own directory also. + +Re-usable components and styles will come from the Stellar Design System. + +### `/config` + +App configuration. Redux store root lives there (`store.ts`). + +### `/constants` + +For various constants used in the app + +### `/ducks` + +Every file in the `/ducks` directory is a reducer in the Redux state (must be +added to the root `config/store.ts`). Inside every reducer are dispatch actions +to update the state (we follow Redux Toolkit conventions). + +![Red banner: You are using PUBLIC network in DEVELOPMENT](public/images/doc-redux-state.png) +_Redux state illustration_ + +### `/helpers` + +Smaller, more generic helper functions + +### `/hooks` + +Custom hooks + +### `/methods` + +Methods are more specific than helper functions. SEPs go into their own +directory (for example, `sep10Auth/`, `sep31Send/`). + +### `/pages` + +Higher level components that match the route (for example, `Landing.tsx`, +`Account.tsx`) + +### `/types` + +TypeScript types diff --git a/capacitor.config.json b/capacitor.config.json deleted file mode 100644 index 4e285bf3..00000000 --- a/capacitor.config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "appId": "com.wallet.stellar", - "appName": "Stellar Wallet", - "bundledWebRuntime": false, - "npmClient": "npm", - "webDir": "www", - "cordova": {} -} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 00000000..2c513fb3 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name localhost; + + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_buffers 16 8k; + gzip_types application/javascript application/rss+xml application/vnd.ms-fontobject application/x-font application/x-font-opentype application/x-font-otf application/x-font-truetype application/x-font-ttf application/x-javascript application/xhtml+xml application/xml font/opentype font/otf font/ttf image/svg+xml image/x-icon text/css text/javascript text/plain text/xml; + + location / { + root /usr/share/nginx/html; + try_files $uri /index.html index.htm; + gzip_static on; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/package.json b/package.json index 3bfd55af..06676d82 100644 --- a/package.json +++ b/package.json @@ -1,61 +1,103 @@ { "name": "stellar-demo-wallet", - "version": "0.0.47", + "version": "0.1.0", "description": "Stellar Demo Wallet", "repository": "https://github.com/stellar/stellar-demo-wallet", "license": "Apache-2.0", - "main": "dist/index.cjs.js", - "module": "./dist/index.js", - "es2015": "./dist/esm/index.mjs", - "es2017": "./dist/esm/index.mjs", - "types": "dist/types/index.d.ts", - "collection": "dist/collection/collection-manifest.json", - "collection:main": "dist/collection/index.js", - "unpkg": "dist/stellar-demo-wallet/stellar-demo-wallet.js", - "files": [ - "dist/", - "loader/" - ], - "scripts": { - "prepare": "yarn build", - "build": "stencil build --docs", - "start": "stencil build --dev --watch --serve", - "generate": "stencil generate", - "deploy": "npm publish ; vercel --prod --confirm --name stellar-demo-wallet", - "test": "stencil test --spec", - "test.watch": "stencil test --spec --watch", - "test.e2e": "stencil test --e2e" - }, - "devDependencies": { - "@stencil/core": "^2.3.0", - "@stencil/postcss": "^2.0.0", - "@stencil/sass": "^1.3.2", - "@types/autoprefixer": "^9.7.2", - "@types/jest": "26.0.15", - "@types/node": "^14.14.20", - "@types/puppeteer": "5.4.0", - "autoprefixer": "^9.8.6", - "copy-to-clipboard": "^3.3.1", - "husky": "^4.3.0", - "jest": "26.6.3", - "jest-cli": "26.6.3", - "js-combinatorics": "^0.6.1", - "lodash": "^4.17.20", - "lodash-es": "^4.17.15", - "prettier": "^2.1.2", - "pretty-quick": "^2.0.2", - "puppeteer": "5.4.1", - "rollup-plugin-node-polyfills": "^0.2.1", - "stellar-sdk": "^6.2.0", - "ts-jest": "^26.4.4", - "typescript": "^4.1.2" + "engines": { + "node": "14.x" }, "husky": { "hooks": { - "pre-commit": "pretty-quick --staged" + "pre-commit": "concurrently 'pretty-quick --staged' 'lint-staged' 'tsc --noEmit'", + "post-merge": "yarn install-if-package-changed" } }, + "lint-staged": { + "src/**/*.ts?(x)": [ + "eslint --fix --max-warnings 0" + ] + }, "dependencies": { - "toml": "^3.0.0" + "@reduxjs/toolkit": "^1.5.0", + "@sentry/browser": "^6.2.5", + "@sentry/tracing": "^6.2.5", + "@stellar/design-system": "^0.1.0-alpha.3", + "@stellar/prettier-config": "^1.0.1", + "@stellar/wallet-sdk": "^0.3.0-rc.4", + "@testing-library/jest-dom": "^5.11.9", + "@testing-library/react": "^11.2.3", + "@testing-library/user-event": "^12.6.0", + "@types/jest": "^26.0.20", + "@types/node": "^14.14.20", + "@types/react": "^17.0.0", + "@types/react-copy-to-clipboard": "^5.0.0", + "@types/react-dom": "^17.0.0", + "@types/react-redux": "^7.1.15", + "@types/react-router-dom": "^5.1.7", + "bignumber.js": "^9.0.1", + "crypto": "^1.0.1", + "dompurify": "^2.2.7", + "html-react-parser": "^1.2.4", + "lodash": "^4.17.19", + "marked": "^2.0.1", + "node-sass": "^4.14.1", + "react": "^17.0.1", + "react-copy-to-clipboard": "^5.0.3", + "react-dom": "^17.0.1", + "react-json-view": "^1.21.1", + "react-redux": "^7.2.2", + "react-router-dom": "^5.2.0", + "react-scripts": "4.0.1", + "redux": "^4.0.5", + "stellar-sdk": "^8.0.0", + "styled-components": "^5.2.1", + "toml": "^3.0.0", + "typescript": "~4.1.3", + "web-vitals": "^0.2.4" + }, + "scripts": { + "install-if-package-changed": "git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD | grep --quiet yarn.lock && yarn install || exit 0", + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "prod:build": "docker image build --build-arg REACT_APP_AMPLITUDE_KEY=$AMPLITUDE_KEY --build-arg REACT_APP_SENTRY_KEY=$SENTRY_KEY -t stellar-demo-wallet:localbuild .", + "prod:serve": "docker run -p 8000:80 stellar-demo-wallet:localbuild", + "production": "yarn prod:build && yarn prod:serve" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@stellar/eslint-config": "^1.0.5", + "@stellar/tsconfig": "^1.0.2", + "@types/lodash": "^4.14.167", + "@types/marked": "^2.0.0", + "@types/redux": "^3.6.0", + "@types/styled-components": "^5.1.7", + "concurrently": "^5.3.0", + "eslint": "^7.17.0", + "eslint-config-prettier": "^7.1.0", + "eslint-config-react": "^1.1.7", + "husky": "^4.3.7", + "lint-staged": "^10.5.3", + "prettier": "^2.2.1", + "pretty-quick": "^3.1.0" } } diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 00000000..4b355cb3 --- /dev/null +++ b/prettier.config.js @@ -0,0 +1 @@ +module.exports = require("@stellar/prettier-config"); diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000..4f17c94a Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/images/doc-redux-state.png b/public/images/doc-redux-state.png new file mode 100644 index 00000000..bf6a330e Binary files /dev/null and b/public/images/doc-redux-state.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 00000000..fc492fa3 --- /dev/null +++ b/public/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + Demo Wallet - Stellar + + + +
+ + diff --git a/public/logo192.png b/public/logo192.png new file mode 100644 index 00000000..476ff8d0 Binary files /dev/null and b/public/logo192.png differ diff --git a/public/logo512.png b/public/logo512.png new file mode 100644 index 00000000..41427a2f Binary files /dev/null and b/public/logo512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 00000000..3c422014 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "Account Viewer", + "name": "Stellar Account Viewer", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 00000000..e9e57dc4 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/src/App.scss b/src/App.scss new file mode 100644 index 00000000..81ca3684 --- /dev/null +++ b/src/App.scss @@ -0,0 +1,441 @@ +:root { + --size-min-window: 800px; + --size-max-window: 1296px; +} + +body { + overflow-y: hidden; +} + +p { + word-break: break-word; +} + +code { + // TODO: color not in SDS + color: #490be3; + font-family: var(--font-family-monospace); + border-radius: 0.1875rem; + border: 0.5px solid var(--color-background-off); + background-color: var(--color-background-secondary); + padding: 0 0.25rem; + font-size: 0.875rem; + line-height: 1.5rem; + font-weight: var(--font-weight-medium); + display: inline; + line-break: anywhere; +} + +@mixin header-footer-inset { + height: 3rem; + display: flex; + align-items: center; +} + +@mixin header-footer-vertical-padding { + padding-top: 1rem; +} + +.Wrapper { + min-width: var(--size-min-window); + display: flex; + flex-grow: 1; + position: relative; +} + +.SplitContainer { + width: 50%; + position: relative; +} + +.ContentWrapper { + display: flex; + flex-direction: column; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + overflow: auto; +} + +.Announcement { + background-color: var(--color-note); + color: var(--color-text-contrast); + text-align: center; + width: 100%; + margin-bottom: 2rem; + padding: 1rem 0; //vertical padding + // change anchor text to reflect same color as the rest of the announcment + .TextLink { + color: inherit; + } +} + +.Main { + background-color: var(--color-background-main); +} + +.Inset { + position: relative; + margin: 0 auto; + padding-left: 1rem; + padding-right: 1rem; + width: 100%; + max-width: var(--size-max-window); +} + +.Header { + @include header-footer-vertical-padding; + padding-bottom: 1rem; + + .Inset { + @include header-footer-inset; + justify-content: space-between; + } +} + +.Footer { + @include header-footer-vertical-padding; + + .Inset { + @include header-footer-inset; + justify-content: space-between; + + a:not(:last-child) { + margin-right: 1.5rem; + } + } +} + +.IntroText { + margin-bottom: 3rem; +} + +.LandingButtons { + button { + text-align: left; + } +} + +.Inline { + display: flex; + align-items: center; + position: relative; + + & > *:not(:last-child) { + margin-right: 1rem; + } +} + +.LoadingBlock { + margin-bottom: 1rem; +} + +.InfoButtonWrapper { + position: relative; + display: flex; + align-items: center; +} + +.Section { + margin-top: 3rem; +} + +// Generic +.error { + color: var(--color-error); +} + +.success { + color: var(--color-success); +} + +.vertical-spacing { + & > * { + margin-bottom: 1rem; + } +} + +.horizontal-spacing { + & > *:not(:first-child), + & > *:not(:last-child) { + margin-right: 1.5rem; + } +} + +// Account +.Account { + display: flex; + flex-direction: column; + margin-bottom: 1rem; + + .AccountInfo { + display: table; + max-width: 276px; + + &:first-child { + margin-bottom: 1rem; + margin-right: 2rem; + } + + .AccountInfoRow { + display: table-row; + height: 2rem; + } + + .AccountInfoCell { + display: table-cell; + vertical-align: middle; + + button { + padding-top: 0.1rem; + padding-bottom: 0.1rem; + text-align: left; + } + + &.AccountLabel { + text-transform: uppercase; + } + + &.CopyButton { + width: 3.75rem; + } + + &:not(:last-child) { + padding-right: 1rem; + } + } + } + + @media (min-width: 1020px) { + flex-direction: row; + + .AccountInfo { + &:first-child { + margin-bottom: 0; + } + } + } +} + +.AccountDetails { + .AccountDetailsContent { + padding: 1rem; + font-weight: var(--font-weight-light); + font-family: var(--font-family-monospace); + font-size: 0.875rem; + line-height: 1.375rem; + word-break: break-word; + } +} + +.Balances { + border-top: 1px solid var(--color-border-main); + + .BalanceRow { + border-bottom: 1px solid var(--color-border-main); + padding-top: 1rem; + padding-bottom: 1rem; + + &.disabled { + background-color: var(--color-background-off); + cursor: not-allowed; + + .BalanceCell.BalanceInfo { + opacity: 0.6; + } + + a { + pointer-events: none; + } + } + + &.active { + background-color: #dfd8ff; + } + } + + .BalanceCell { + position: relative; + + &:not(:last-child) { + margin-bottom: 1rem; + } + + & > :not(:last-child) { + margin-bottom: 0.5rem; + } + + &.BalanceInfo { + .BalanceAmount { + font-size: 1.1em; + font-weight: var(--font-weight-medium); + + &.error { + font-weight: var(--font-weight-normal); + } + } + + .BalanceOptions { + margin-top: 0.3rem; + display: flex; + align-items: center; + position: relative; + + & > *:not(:last-child) { + margin-right: 0.5rem; + } + } + } + + &.BalanceActions { + .BalanceCellSelect { + display: flex; + align-items: center; + + & > :first-child { + margin-right: 0.5rem; + } + } + + .CustomCell { + display: flex; + align-items: center; + } + } + } + + @media (min-width: 1260px) { + .BalanceRow { + padding-top: 0.7rem; + padding-bottom: 0.7rem; + display: flex; + align-items: center; + justify-content: space-between; + } + + .BalanceCell { + &:not(:last-child) { + margin-bottom: 0; + } + + & > :not(:last-child) { + margin-bottom: 0; + } + + &.BalanceActions { + display: flex; + + .BalanceCellSelect { + width: 14rem; + flex-grow: 0; + flex-shrink: 0; + } + + .CustomCell { + &:not(:last-child) { + margin-right: 1rem; + } + + justify-content: flex-end; + } + } + } + } +} + +.ClaimableBalances { + margin-top: 3rem; +} + +.BalancesButtons { + margin-top: 1.5rem; +} + +// Logs +.Logs { + background-color: var(--color-background-off); + overflow: auto; + display: flex; + flex-direction: column; + + .LogsWrapper { + position: relative; + flex-grow: 1; + } + + .ContentWrapper { + flex-direction: column-reverse; + } + + .LogsFooter { + background-color: var(--color-background-main); + border: 1px solid var(--color-border-main); + + .Inset { + @include header-footer-inset; + + a:not(:last-child) { + margin-right: 1.5rem; + } + } + } + + .LogsContent { + margin-top: 1rem; + margin-bottom: 1rem; + } + + .EmptyLogsContent { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding: 1rem; + color: var(--color-note); + } + + .LogItem { + margin-bottom: 1rem; + } +} + +.SessionParamsWrapper { + display: flex; + margin-top: -0.5rem; +} + +// Configuration +.ConfigurationItem { + display: flex; + align-items: center; + justify-content: space-between; +} + +// Old styles + +.Content { + flex-grow: 1; + flex-shrink: 0; +} + +.Block { + & > * { + margin-bottom: 1rem; + } +} + +.SendForm { + max-width: 600px; + margin-bottom: 1rem; +} + +.SendFormButtons { + display: flex; + align-items: center; + + & > * { + margin-right: 1rem; + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 00000000..60e292b2 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,89 @@ +import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; +import { Provider } from "react-redux"; +import * as Sentry from "@sentry/browser"; +import { Integrations } from "@sentry/tracing"; + +import { store } from "config/store"; +import { Header } from "components/Header"; +import { Footer } from "components/Footer"; +import { Logs } from "components/Logs"; +import { PageContent } from "components/PageContent"; +import { PrivateRoute } from "components/PrivateRoute"; +import { SettingsHandler } from "components/SettingsHandler"; +import { WarningBanner } from "components/WarningBanner"; +import { TextLink, TextLinkVariant } from "@stellar/design-system"; + +import { Account } from "pages/Account"; +import { Landing } from "pages/Landing"; +import { NotFound } from "pages/NotFound"; +import "./App.scss"; + +if (process.env.REACT_APP_SENTRY_KEY) { + Sentry.init({ + dsn: process.env.REACT_APP_SENTRY_KEY, + release: `demo-wallet@${process.env.npm_package_version}`, + integrations: [new Integrations.BrowserTracing()], + tracesSampleRate: 1.0, + }); +} + +export const App = () => ( + + + + + +
+
+
+
+ +
+
+

+ Welcome to the new and improved Stellar demo wallet! Please + log bugs and feature requests at:   + + https://github.com/stellar/stellar-demo-wallet/issues + +

+
+
+ +
+

+ This demo wallet lets financial application developers test + their integrations and learn how Stellar ecosystem protocols + (SEPs) work. +

+
+ + + + + + + + + + + + + + + +
+
+
+ + +
+
+
+
+); diff --git a/src/assets/icons/arrow-left.svg b/src/assets/icons/arrow-left.svg new file mode 100644 index 00000000..d839f8de --- /dev/null +++ b/src/assets/icons/arrow-left.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/arrow-right.svg b/src/assets/icons/arrow-right.svg new file mode 100644 index 00000000..879fdc34 --- /dev/null +++ b/src/assets/icons/arrow-right.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/bubble.svg b/src/assets/icons/bubble.svg new file mode 100644 index 00000000..51e81062 --- /dev/null +++ b/src/assets/icons/bubble.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/edit.svg b/src/assets/icons/edit.svg new file mode 100644 index 00000000..182fd13c --- /dev/null +++ b/src/assets/icons/edit.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/error.svg b/src/assets/icons/error.svg new file mode 100644 index 00000000..879952ed --- /dev/null +++ b/src/assets/icons/error.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/components.d.ts b/src/components.d.ts deleted file mode 100644 index 7cb527e9..00000000 --- a/src/components.d.ts +++ /dev/null @@ -1,125 +0,0 @@ -/* eslint-disable */ -/* tslint:disable */ -/** - * This is an autogenerated file created by the Stencil compiler. - * It contains typing information for all components that exist in this project. - */ -import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; -import { Prompter } from "./components/prompt/prompt"; -import { Server } from "stellar-sdk"; -import { ILogger } from "./components/logview/logview"; -export namespace Components { - interface CollapsibleContainer { - "hideText": string; - "showText": string; - } - interface JsonViewer { - "data": any; - } - interface LogView { - "error": (title: string, body?: string) => Promise; - "instruction": (title: string, body?: string) => Promise; - "request": (url: string, body?: string | object) => Promise; - "response": (url: string, body?: string | object) => Promise; - } - interface StellarLoader { - "interval": any; - } - interface StellarPrompt { - "prompter": Prompter; - } - interface StellarWallet { - "logger": ILogger; - "network_passphrase": string; - "server": Server; - } -} -declare global { - interface HTMLCollapsibleContainerElement extends Components.CollapsibleContainer, HTMLStencilElement { - } - var HTMLCollapsibleContainerElement: { - prototype: HTMLCollapsibleContainerElement; - new (): HTMLCollapsibleContainerElement; - }; - interface HTMLJsonViewerElement extends Components.JsonViewer, HTMLStencilElement { - } - var HTMLJsonViewerElement: { - prototype: HTMLJsonViewerElement; - new (): HTMLJsonViewerElement; - }; - interface HTMLLogViewElement extends Components.LogView, HTMLStencilElement { - } - var HTMLLogViewElement: { - prototype: HTMLLogViewElement; - new (): HTMLLogViewElement; - }; - interface HTMLStellarLoaderElement extends Components.StellarLoader, HTMLStencilElement { - } - var HTMLStellarLoaderElement: { - prototype: HTMLStellarLoaderElement; - new (): HTMLStellarLoaderElement; - }; - interface HTMLStellarPromptElement extends Components.StellarPrompt, HTMLStencilElement { - } - var HTMLStellarPromptElement: { - prototype: HTMLStellarPromptElement; - new (): HTMLStellarPromptElement; - }; - interface HTMLStellarWalletElement extends Components.StellarWallet, HTMLStencilElement { - } - var HTMLStellarWalletElement: { - prototype: HTMLStellarWalletElement; - new (): HTMLStellarWalletElement; - }; - interface HTMLElementTagNameMap { - "collapsible-container": HTMLCollapsibleContainerElement; - "json-viewer": HTMLJsonViewerElement; - "log-view": HTMLLogViewElement; - "stellar-loader": HTMLStellarLoaderElement; - "stellar-prompt": HTMLStellarPromptElement; - "stellar-wallet": HTMLStellarWalletElement; - } -} -declare namespace LocalJSX { - interface CollapsibleContainer { - "hideText"?: string; - "showText"?: string; - } - interface JsonViewer { - "data"?: any; - } - interface LogView { - } - interface StellarLoader { - "interval"?: any; - } - interface StellarPrompt { - "prompter"?: Prompter; - } - interface StellarWallet { - "logger"?: ILogger; - "network_passphrase"?: string; - "server"?: Server; - } - interface IntrinsicElements { - "collapsible-container": CollapsibleContainer; - "json-viewer": JsonViewer; - "log-view": LogView; - "stellar-loader": StellarLoader; - "stellar-prompt": StellarPrompt; - "stellar-wallet": StellarWallet; - } -} -export { LocalJSX as JSX }; -declare module "@stencil/core" { - export namespace JSX { - interface IntrinsicElements { - "collapsible-container": LocalJSX.CollapsibleContainer & JSXBase.HTMLAttributes; - "json-viewer": LocalJSX.JsonViewer & JSXBase.HTMLAttributes; - "log-view": LocalJSX.LogView & JSXBase.HTMLAttributes; - "stellar-loader": LocalJSX.StellarLoader & JSXBase.HTMLAttributes; - "stellar-prompt": LocalJSX.StellarPrompt & JSXBase.HTMLAttributes; - "stellar-wallet": LocalJSX.StellarWallet & JSXBase.HTMLAttributes; - } - } -} diff --git a/src/components/AccountInfo.tsx b/src/components/AccountInfo.tsx new file mode 100644 index 00000000..544fefc5 --- /dev/null +++ b/src/components/AccountInfo.tsx @@ -0,0 +1,172 @@ +import { useCallback, useState } from "react"; +import { useDispatch } from "react-redux"; +import { Heading2, Loader } from "@stellar/design-system"; +import { TextButton } from "components/TextButton"; +import { TextLink } from "components/TextLink"; +import ReactJson from "react-json-view"; + +import { CopyWithText } from "components/CopyWithText"; +import { ToastBanner } from "components/ToastBanner"; + +import { fetchAccountAction, fundTestnetAccount } from "ducks/account"; +import { fetchClaimableBalancesAction } from "ducks/claimableBalances"; + +import { shortenStellarKey } from "helpers/shortenStellarKey"; +import { useRedux } from "hooks/useRedux"; +import { ActionStatus } from "types/types.d"; + +export const AccountInfo = () => { + const { account } = useRedux("account"); + const [isAccountDetailsVisible, setIsAccountDetailsVisible] = useState(false); + + const dispatch = useDispatch(); + + const handleRefreshAccount = useCallback(() => { + if (account.data?.id) { + dispatch( + fetchAccountAction({ + publicKey: account.data.id, + secretKey: account.secretKey, + }), + ); + dispatch(fetchClaimableBalancesAction({ publicKey: account.data.id })); + } + }, [account.data?.id, account.secretKey, dispatch]); + + const handleCreateAccount = () => { + if (account.data?.id) { + dispatch(fundTestnetAccount(account.data.id)); + } + }; + + if (!account.data?.id) { + return null; + } + + return ( +
+
+ {/* Account keys */} +
+
+
Public
+
+ {shortenStellarKey(account.data.id)} +
+
+ +
+
+
+
Secret
+
+ {shortenStellarKey(account.secretKey)} +
+
+ +
+
+
+ + {/* Account actions */} +
+
+
+ {account.isUnfunded && ( +
+ + Clicking create will fund your test account with XLM. If + you’re testing SEP-24 you may want to leave this account + unfunded.{" "} + + Learn more + + + } + > + Create account + +
+ )} + + {!account.isUnfunded && ( + + setIsAccountDetailsVisible(!isAccountDetailsVisible) + } + >{`${ + isAccountDetailsVisible ? "Hide" : "Show" + } account details`} + )} +
+
+ +
+
+
+ + Refresh account + +
+
+
+
+
+ + {/* Account details */} + {isAccountDetailsVisible && ( +
+ Account details +
+ +
+
+ )} + + +
+ Updating account + +
+
+
+ ); +}; diff --git a/src/components/AddAsset.tsx b/src/components/AddAsset.tsx new file mode 100644 index 00000000..56cbec06 --- /dev/null +++ b/src/components/AddAsset.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState } from "react"; +import { useHistory } from "react-router-dom"; +import { + Button, + Heading2, + InfoBlock, + InfoBlockVariant, + Loader, +} from "@stellar/design-system"; +import { Input } from "components/Input"; +import { getErrorMessage } from "helpers/getErrorMessage"; +import { getNetworkConfig } from "helpers/getNetworkConfig"; +import { getValidatedUntrustedAsset } from "helpers/getValidatedUntrustedAsset"; +import { searchParam } from "helpers/searchParam"; +import { log } from "helpers/log"; +import { useRedux } from "hooks/useRedux"; +import { ActionStatus, SearchParams } from "types/types.d"; +import { TextLink } from "./TextLink"; + +export const AddAsset = ({ onClose }: { onClose: () => void }) => { + const { account, settings, untrustedAssets } = useRedux( + "account", + "settings", + "untrustedAssets", + ); + + const [isValidating, setIsValidating] = useState(false); + // Form data + const [assetCode, setAssetCode] = useState(""); + const [homeDomain, setHomeDomain] = useState(""); + const [issuerPublicKey, setIssuerPublicKey] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + + const history = useHistory(); + + const resetState = () => { + setAssetCode(""); + setHomeDomain(""); + setIssuerPublicKey(""); + setErrorMessage(""); + setIsValidating(false); + }; + + useEffect(() => () => resetState(), []); + + useEffect(() => { + if (untrustedAssets.status === ActionStatus.SUCCESS) { + onClose(); + } + + if (untrustedAssets.errorString) { + setErrorMessage(untrustedAssets.errorString); + } + }, [untrustedAssets.status, untrustedAssets.errorString, onClose]); + + const handleSetUntrustedAsset = async () => { + setErrorMessage(""); + + if (!(homeDomain || issuerPublicKey)) { + const errorMsg = + "Home domain OR issuer public key is required with asset code"; + + log.error({ title: errorMsg }); + setErrorMessage(errorMsg); + return; + } + + setIsValidating(true); + + try { + const asset = await getValidatedUntrustedAsset({ + assetCode, + homeDomain, + issuerPublicKey, + accountBalances: account.data?.balances, + networkUrl: getNetworkConfig(settings.pubnet).url, + }); + + let search = searchParam.update( + SearchParams.UNTRUSTED_ASSETS, + `${asset.assetCode}:${asset.assetIssuer}`, + ); + + if (asset.homeDomain) { + search = searchParam.updateKeyPair({ + searchParam: SearchParams.ASSET_OVERRIDES, + itemId: `${asset.assetCode}:${asset.assetIssuer}`, + keyPairs: { homeDomain }, + urlSearchParams: new URLSearchParams(search), + }); + } + + history.push(search); + setIsValidating(false); + } catch (e) { + const errorMsg = getErrorMessage(e); + + log.error({ title: errorMsg }); + setErrorMessage(errorMsg); + setIsValidating(false); + } + }; + + const isPending = + isValidating || untrustedAssets.status === ActionStatus.PENDING; + + return ( + <> + {/* TODO: move to Modal component */} + Add asset + +
+

Required: asset code AND (home domain OR issuer)

+ + { + setErrorMessage(""); + setAssetCode(e.target.value); + }} + value={assetCode} + placeholder="ex: USDC, EURT, NGNT" + tooltipText={ + <> + Assets are identified by 1) their code and 2) either a home domain + or the public key of the issuing account.{" "} + + Learn more + + + } + /> + + { + setErrorMessage(""); + setHomeDomain(e.target.value); + }} + value={homeDomain} + placeholder="ex: example.com" + tooltipText={ + <> + Domain where the well-known TOML file can be found for this asset.{" "} + + Learn more + + + } + /> + + { + setErrorMessage(""); + setIssuerPublicKey(e.target.value); + }} + value={issuerPublicKey} + placeholder="ex: GCDNJUBQSX7AJWLJACMJ7I4BC3Z47BQUTMHEICZLE6MU4KQBRYG5JY6B" + tooltipText={ + <> + Public key for the Asset Issuer.{" "} + + Learn more + + + } + /> + + {errorMessage && ( + +

{errorMessage}

+
+ )} +
+ +
+ {isPending && } + + +
+ + ); +}; diff --git a/src/components/Assets.tsx b/src/components/Assets.tsx new file mode 100644 index 00000000..84300de0 --- /dev/null +++ b/src/components/Assets.tsx @@ -0,0 +1,378 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; +import { useHistory } from "react-router-dom"; +import { Button, Heading2, Loader } from "@stellar/design-system"; + +import { AddAsset } from "components/AddAsset"; +import { Balance } from "components/Balance"; +import { ClaimableBalance } from "components/ClaimableBalance"; +import { ConfirmAssetAction } from "components/ConfirmAssetAction"; +import { Modal } from "components/Modal"; +import { ToastBanner } from "components/ToastBanner"; +import { UntrustedBalance } from "components/UntrustedBalance"; + +import { fetchAccountAction, resetAccountStatusAction } from "ducks/account"; +import { + setActiveAssetAction, + setActiveAssetStatusAction, + resetActiveAssetAction, +} from "ducks/activeAsset"; +import { + getAllAssetsAction, + resetAllAssetsStatusAction, +} from "ducks/allAssets"; +import { + addAssetOverridesAction, + resetAssetOverridesStatusAction, +} from "ducks/assetOverrides"; +import { resetClaimAssetAction } from "ducks/claimAsset"; +import { fetchClaimableBalancesAction } from "ducks/claimableBalances"; +import { resetSep24DepositAssetAction } from "ducks/sep24DepositAsset"; +import { resetTrustAssetAction } from "ducks/trustAsset"; +import { + removeUntrustedAssetAction, + resetUntrustedAssetStatusAction, +} from "ducks/untrustedAssets"; +import { resetSep24WithdrawAssetAction } from "ducks/sep24WithdrawAsset"; + +import { searchParam } from "helpers/searchParam"; +import { useRedux } from "hooks/useRedux"; +import { + Asset, + ActionStatus, + AssetActionItem, + SearchParams, + TransactionStatus, +} from "types/types.d"; + +export const Assets = ({ + onSendPayment, +}: { + onSendPayment: (asset?: Asset) => void; +}) => { + const { + account, + activeAsset, + allAssets, + assetOverrides, + claimAsset, + sep24DepositAsset, + sep24WithdrawAsset, + sep31Send, + settings, + trustAsset, + untrustedAssets, + } = useRedux( + "account", + "activeAsset", + "allAssets", + "assetOverrides", + "claimAsset", + "sep24DepositAsset", + "sep24WithdrawAsset", + "sep31Send", + "settings", + "trustAsset", + "untrustedAssets", + ); + + const [activeModal, setActiveModal] = useState(""); + const [toastMessage, setToastMessage] = useState(); + + const dispatch = useDispatch(); + const history = useHistory(); + + enum modalType { + ADD_ASSET = "ADD_ASSET", + CONFIRM_ACTION = "CONFIRM_ACTION", + } + + const handleRemoveUntrustedAsset = useCallback( + (removeAsset?: string) => { + if (removeAsset) { + history.push( + searchParam.remove(SearchParams.UNTRUSTED_ASSETS, removeAsset), + ); + dispatch(removeUntrustedAssetAction(removeAsset)); + } + }, + [history, dispatch], + ); + + const handleRefreshAccount = useCallback(() => { + if (account.data?.id) { + dispatch( + fetchAccountAction({ + publicKey: account.data.id, + secretKey: account.secretKey, + }), + ); + } + }, [account.data?.id, account.secretKey, dispatch]); + + const handleFetchClaimableBalances = useCallback(() => { + if (account.data?.id) { + dispatch(fetchClaimableBalancesAction({ publicKey: account.data.id })); + } + }, [account.data?.id, dispatch]); + + const handleCloseModal = () => { + setActiveModal(""); + dispatch(resetActiveAssetAction()); + }; + + const handleAssetAction = ({ + assetString, + balance, + callback, + title, + description, + options, + }: AssetActionItem) => { + setActiveModal(modalType.CONFIRM_ACTION); + dispatch( + setActiveAssetAction({ + assetString, + title, + description, + callback: () => { + setActiveModal(""); + callback(balance); + }, + options, + }), + ); + }; + + const setActiveAssetStatusAndToastMessage = useCallback( + ({ + status, + message, + }: { + status: ActionStatus | undefined; + message: string | React.ReactNode; + }) => { + if (!status) { + return; + } + + if (status === ActionStatus.SUCCESS || status === ActionStatus.ERROR) { + dispatch(resetActiveAssetAction()); + } + + if ( + status === ActionStatus.PENDING || + status === ActionStatus.NEEDS_INPUT + ) { + dispatch(setActiveAssetStatusAction(ActionStatus.PENDING)); + setToastMessage(message); + } + }, + [dispatch], + ); + + useEffect(() => { + if (!activeAsset.action) { + setToastMessage(undefined); + } + }, [activeAsset.action]); + + useEffect(() => { + if (account.status === ActionStatus.SUCCESS) { + dispatch(resetAccountStatusAction()); + dispatch(getAllAssetsAction()); + } + }, [account.status, dispatch]); + + useEffect(() => { + if (allAssets.status === ActionStatus.SUCCESS) { + dispatch(resetAllAssetsStatusAction()); + } + }, [allAssets.status, dispatch]); + + useEffect(() => { + dispatch(addAssetOverridesAction(settings.assetOverrides)); + }, [settings.assetOverrides, dispatch]); + + useEffect(() => { + if (assetOverrides.status === ActionStatus.SUCCESS) { + dispatch(resetAssetOverridesStatusAction()); + dispatch(getAllAssetsAction()); + } + }, [assetOverrides.status, dispatch]); + + // Trust asset + useEffect(() => { + if (trustAsset.status === ActionStatus.SUCCESS) { + history.push( + searchParam.remove( + SearchParams.UNTRUSTED_ASSETS, + trustAsset.assetString, + ), + ); + dispatch(removeUntrustedAssetAction(trustAsset.assetString)); + dispatch(resetTrustAssetAction()); + handleRefreshAccount(); + } + + setActiveAssetStatusAndToastMessage({ + status: trustAsset.status, + message: "Trust asset in progress", + }); + }, [ + trustAsset.status, + trustAsset.assetString, + handleRefreshAccount, + setActiveAssetStatusAndToastMessage, + dispatch, + history, + ]); + + // Deposit asset + useEffect(() => { + if (sep24DepositAsset.status === ActionStatus.SUCCESS) { + dispatch(resetSep24DepositAssetAction()); + + if (sep24DepositAsset.data.trustedAssetAdded) { + handleRemoveUntrustedAsset(sep24DepositAsset.data.trustedAssetAdded); + } + + if ( + sep24DepositAsset.data.currentStatus === TransactionStatus.COMPLETED + ) { + handleRefreshAccount(); + handleFetchClaimableBalances(); + } + } + + setActiveAssetStatusAndToastMessage({ + status: sep24DepositAsset.status, + message: "SEP-24 deposit in progress", + }); + }, [ + sep24DepositAsset.status, + sep24DepositAsset.data.currentStatus, + sep24DepositAsset.data.trustedAssetAdded, + handleRefreshAccount, + handleFetchClaimableBalances, + handleRemoveUntrustedAsset, + setActiveAssetStatusAndToastMessage, + dispatch, + history, + ]); + + // Withdraw asset + useEffect(() => { + if (sep24WithdrawAsset.status === ActionStatus.SUCCESS) { + dispatch(resetSep24WithdrawAssetAction()); + + if ( + sep24WithdrawAsset.data.currentStatus === TransactionStatus.COMPLETED + ) { + handleRefreshAccount(); + } + } + + setActiveAssetStatusAndToastMessage({ + status: sep24WithdrawAsset.status, + message: "SEP-24 withdrawal in progress", + }); + }, [ + sep24WithdrawAsset.status, + sep24WithdrawAsset.data.currentStatus, + handleRefreshAccount, + setActiveAssetStatusAndToastMessage, + dispatch, + history, + ]); + + // Claim asset + useEffect(() => { + if (claimAsset.status === ActionStatus.SUCCESS) { + handleRemoveUntrustedAsset(claimAsset.data.trustedAssetAdded); + dispatch(resetClaimAssetAction()); + handleRefreshAccount(); + handleFetchClaimableBalances(); + } + + setActiveAssetStatusAndToastMessage({ + status: claimAsset.status, + message: "Claim asset in progress", + }); + }, [ + claimAsset.status, + claimAsset.data.trustedAssetAdded, + account.data?.id, + handleRefreshAccount, + handleFetchClaimableBalances, + handleRemoveUntrustedAsset, + setActiveAssetStatusAndToastMessage, + dispatch, + ]); + + // SEP-31 Send + useEffect(() => { + setActiveAssetStatusAndToastMessage({ + status: sep31Send.status, + message: "SEP-31 send in progress", + }); + }, [sep31Send.status, setActiveAssetStatusAndToastMessage]); + + // Remove untrusted asset + useEffect(() => { + if ( + untrustedAssets.status === ActionStatus.SUCCESS || + untrustedAssets.status === ActionStatus.ERROR + ) { + dispatch(getAllAssetsAction()); + dispatch(resetUntrustedAssetStatusAction()); + dispatch(resetActiveAssetAction()); + } + }, [untrustedAssets.status, dispatch]); + + return ( + <> + {/* Balances */} +
+
+ Balances +
+
+ + +
+ +
+ +
+
+ + {/* Claimable balances */} + + + + {/* Action confirmation */} + {activeModal === modalType.CONFIRM_ACTION && ( + + )} + + {/* Add asset */} + {activeModal === modalType.ADD_ASSET && ( + + )} + + + +
+
{toastMessage}
+ +
+
+ + ); +}; diff --git a/src/components/Balance.tsx b/src/components/Balance.tsx new file mode 100644 index 00000000..49ceefb7 --- /dev/null +++ b/src/components/Balance.tsx @@ -0,0 +1,180 @@ +import { useDispatch } from "react-redux"; +import { TextLink } from "components/TextLink"; +import { BalanceRow } from "components/BalanceRow"; +import { depositAssetAction } from "ducks/sep24DepositAsset"; +import { fetchSendFieldsAction } from "ducks/sep31Send"; +import { withdrawAssetAction } from "ducks/sep24WithdrawAsset"; +import { useRedux } from "hooks/useRedux"; +import { + Asset, + AssetActionItem, + AssetActionId, + AssetType, + AssetCategory, +} from "types/types.d"; + +interface SortedBalancesResult { + native: Asset[]; + other: Asset[]; +} + +export const Balance = ({ + onAssetAction, + onSend, +}: { + onAssetAction: ({ + balance, + callback, + title, + description, + options, + }: AssetActionItem) => void; + onSend: (asset?: Asset) => void; +}) => { + const { activeAsset, allAssets } = useRedux("activeAsset", "allAssets"); + const allBalances = allAssets.data.filter( + (a) => a.category === AssetCategory.TRUSTED, + ); + + const dispatch = useDispatch(); + + const groupBalances = () => { + if (!allBalances) { + return null; + } + + const result: SortedBalancesResult = { + native: [], + other: [], + }; + + allBalances.map((balance) => { + if (balance.assetType === AssetType.NATIVE) { + result.native = [...result.native, balance]; + } else { + result.other = [...result.other, balance]; + } + + return result; + }); + + return result; + }; + + const handleSep24Deposit = (asset: Asset) => { + dispatch(depositAssetAction(asset)); + }; + + const handleSep24Withdraw = (asset: Asset) => { + dispatch(withdrawAssetAction(asset)); + }; + + const handleSep31Send = (asset: Asset) => { + dispatch(fetchSendFieldsAction(asset)); + }; + + const handleAction = ({ + actionId, + balance, + }: { + actionId: string; + balance: Asset; + }) => { + if (!actionId) { + return; + } + + let props: AssetActionItem | undefined; + const defaultProps = { + assetString: balance.assetString, + balance, + }; + + switch (actionId) { + case AssetActionId.SEND_PAYMENT: + props = { + ...defaultProps, + title: `Send ${balance.assetCode}`, + description: ( +

+ {`Send ${balance.assetCode} on-chain to another account.`}{" "} + + Learn more + +

+ ), + callback: onSend, + }; + break; + case AssetActionId.SEP24_DEPOSIT: + props = { + ...defaultProps, + title: `SEP-24 deposit ${balance.assetCode} (with Trusted Asset)`, + description: `Start SEP-24 deposit of trusted asset ${balance.assetCode}?`, + callback: () => handleSep24Deposit(balance), + }; + break; + case AssetActionId.SEP24_WITHDRAW: + props = { + ...defaultProps, + title: `SEP-24 withdrawal ${balance.assetCode}`, + description: `Start SEP-24 withdrawal of ${balance.assetCode}?`, + callback: () => handleSep24Withdraw(balance), + }; + break; + case AssetActionId.SEP31_SEND: + props = { + ...defaultProps, + title: `SEP-31 send ${balance.assetCode}`, + description: `Start SEP-31 send to ${balance.assetCode}?`, + callback: () => handleSep31Send(balance), + }; + break; + default: + // do nothing + } + + if (!props) { + return; + } + + onAssetAction(props); + }; + + const sortedBalances = groupBalances(); + + if (!sortedBalances) { + return null; + } + + return ( + <> + {/* Native (XLM) balance */} + {sortedBalances.native.map((balance) => ( + + handleAction({ actionId, balance: asset }) + } + /> + ))} + + {/* Other balances */} + {sortedBalances.other.map((balance) => ( + + handleAction({ actionId, balance: asset }) + } + /> + ))} + + ); +}; diff --git a/src/components/BalanceRow.tsx b/src/components/BalanceRow.tsx new file mode 100644 index 00000000..7fc47b8f --- /dev/null +++ b/src/components/BalanceRow.tsx @@ -0,0 +1,141 @@ +import React, { ReactNode, useEffect, useState } from "react"; +import { Select } from "@stellar/design-system"; +import { TextLink } from "components/TextLink"; +import { HomeDomainOverrideButtons } from "components/HomeDomainOverrideButtons"; +import { shortenStellarKey } from "helpers/shortenStellarKey"; +import { + Asset, + ActiveAssetAction, + AssetActionId, + AssetType, + ClaimableAsset, +} from "types/types.d"; +import { InfoButtonWithTooltip } from "./InfoButtonWithTooltip"; + +interface BalanceRowProps { + activeAction: ActiveAssetAction | undefined; + asset: Asset | ClaimableAsset; + onAction?: (actionId: string, asset: Asset) => void; + children?: ReactNode; +} + +export const BalanceRow = ({ + activeAction, + asset, + onAction, + children, +}: BalanceRowProps) => { + const { + assetString, + assetCode, + assetIssuer, + total, + supportedActions, + isUntrusted, + notExist, + homeDomain, + } = asset; + const isActive = activeAction?.assetString === assetString; + const disabled = Boolean(activeAction); + const [selectValue, setSelectValue] = useState(""); + + useEffect(() => { + // reset value to default after modal close + if (!isActive) { + setSelectValue(""); + } + }, [isActive]); + + const handleSelectChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setSelectValue(value); + if (onAction) { + onAction(value, asset); + } + }; + + return ( +
+
+ {notExist ? ( +
{`${assetCode}:${shortenStellarKey( + assetIssuer, + )} does not exist`}
+ ) : ( + <> +
{`${ + total || "0" + } ${assetCode}`}
+
+ {homeDomain && ( + + {homeDomain} + + )} + {!asset.isClaimableBalance && + asset.assetType !== AssetType.NATIVE && ( + + )} +
+ + )} +
+
+ {children &&
{children}
} + + {onAction && ( +
+ + + + <> + { + "What you can do with an asset (deposit, withdraw, or send) depends on what transactions the anchor supports." + }{" "} + + Learn more + + + +
+ )} +
+
+ ); +}; diff --git a/src/components/Banner/index.tsx b/src/components/Banner/index.tsx new file mode 100644 index 00000000..c152877a --- /dev/null +++ b/src/components/Banner/index.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import "./styles.scss"; + +export const Banner = ({ children }: { children: React.ReactNode }) => ( +
+
{children}
+
+); diff --git a/src/components/Banner/styles.scss b/src/components/Banner/styles.scss new file mode 100644 index 00000000..fab8306f --- /dev/null +++ b/src/components/Banner/styles.scss @@ -0,0 +1,10 @@ +// TODO: add to SDS +.Banner { + min-width: var(--size-min-window); + background-color: var(--color-error); + color: var(--color-text-contrast); + text-align: center; + padding-top: 1rem; + padding-bottom: 1rem; + z-index: calc(var(--z-index-modal) + 2); +} diff --git a/src/components/ClaimableBalance.tsx b/src/components/ClaimableBalance.tsx new file mode 100644 index 00000000..ad20ae26 --- /dev/null +++ b/src/components/ClaimableBalance.tsx @@ -0,0 +1,64 @@ +import { Heading2, TextButton } from "@stellar/design-system"; +import { useDispatch } from "react-redux"; +import { BalanceRow } from "components/BalanceRow"; +import { claimAssetAction } from "ducks/claimAsset"; +import { useRedux } from "hooks/useRedux"; +import { AssetActionItem, ClaimableAsset } from "types/types.d"; + +export const ClaimableBalance = ({ + onAssetAction, +}: { + onAssetAction: ({ + balance, + callback, + title, + description, + options, + }: AssetActionItem) => void; +}) => { + const { activeAsset, claimableBalances } = useRedux( + "activeAsset", + "claimableBalances", + ); + const balances = claimableBalances.data.records; + + const dispatch = useDispatch(); + + const handleClaim = (balance: ClaimableAsset) => { + onAssetAction({ + assetString: balance.assetString, + balance, + title: `Claim balance ${balance.assetCode}`, + description: `Claimable balance description ${balance.total} ${balance.assetCode}`, + callback: () => dispatch(claimAssetAction(balance)), + }); + }; + + if (!balances || !balances.length) { + return null; + } + + return ( +
+
+ Claimable Balances +
+
+ {balances.map((balance) => ( + + handleClaim(balance)} + disabled={Boolean(activeAsset.action)} + > + Claim + + + ))} +
+
+ ); +}; diff --git a/src/components/ConfigurationModal.tsx b/src/components/ConfigurationModal.tsx new file mode 100644 index 00000000..bada4e7d --- /dev/null +++ b/src/components/ConfigurationModal.tsx @@ -0,0 +1,43 @@ +import { useHistory } from "react-router-dom"; +import { Heading2, Button } from "@stellar/design-system"; +import { Toggle } from "components/Toggle"; +import { searchParam } from "helpers/searchParam"; +import { useRedux } from "hooks/useRedux"; +import { SearchParams } from "types/types.d"; + +export const ConfigurationModal = ({ onClose }: { onClose: () => void }) => { + const { settings } = useRedux("settings"); + const history = useHistory(); + + const handleClaimableBalanceSupported = () => { + history.push( + searchParam.update( + SearchParams.CLAIMABLE_BALANCE_SUPPORTED, + (!settings.claimableBalanceSupported).toString(), + ), + ); + }; + + return ( + <> + Configuration + +
+
+ + +
+
+ +
+ +
+ + ); +}; diff --git a/src/components/ConfirmAssetAction.tsx b/src/components/ConfirmAssetAction.tsx new file mode 100644 index 00000000..d147b918 --- /dev/null +++ b/src/components/ConfirmAssetAction.tsx @@ -0,0 +1,42 @@ +import { Button, ButtonVariant, Heading2 } from "@stellar/design-system"; +import { useRedux } from "hooks/useRedux"; + +export const ConfirmAssetAction = ({ onClose }: { onClose: () => void }) => { + const { activeAsset } = useRedux("activeAsset"); + + if (!activeAsset?.action) { + return null; + } + + const { title, description, callback, options } = activeAsset.action; + + return ( + <> + {/* TODO: move to Modal component */} + {title} + +
+ {description && + (typeof description === "string" ? ( +

{description}

+ ) : ( + description + ))} + {options &&

{options}

} +
+ +
+ + +
+ + ); +}; diff --git a/src/components/ConnectAccount.tsx b/src/components/ConnectAccount.tsx new file mode 100644 index 00000000..72b953aa --- /dev/null +++ b/src/components/ConnectAccount.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import { + Button, + Checkbox, + Heading2, + Input, + Loader, +} from "@stellar/design-system"; +import { useHistory } from "react-router-dom"; +import { searchParam } from "helpers/searchParam"; +import { useRedux } from "hooks/useRedux"; +import { ActionStatus, SearchParams } from "types/types.d"; + +export const ConnectAccount = () => { + const { account, settings } = useRedux("account", "settings"); + const [secretKey, setSecretKey] = useState(""); + const history = useHistory(); + + const handleSetSecretKey = () => { + history.push(searchParam.update(SearchParams.SECRET_KEY, secretKey)); + }; + + const handleSwitchNetwork = () => { + history.push( + searchParam.update(SearchParams.PUBNET, (!settings.pubnet).toString()), + ); + }; + + return ( + <> + {/* TODO: move to Modal component */} + Connect with a secret key + +
+ setSecretKey(e.target.value)} + value={secretKey} + placeholder="Starts with S, example: SCHK…ZLJK" + /> + + +
+ +
+ {account.status === ActionStatus.PENDING && } + + +
+ + ); +}; diff --git a/src/components/CopyWithText.tsx b/src/components/CopyWithText.tsx new file mode 100644 index 00000000..d4c19825 --- /dev/null +++ b/src/components/CopyWithText.tsx @@ -0,0 +1,35 @@ +import React, { useState } from "react"; +import CopyToClipboard from "react-copy-to-clipboard"; +import { TextButton } from "@stellar/design-system"; + +export const CopyWithText = ({ + textToCopy, + children, +}: { + textToCopy: string; + children?: React.ReactNode; +}) => { + const [inProgress, setInProgress] = useState(false); + + const handleCopy = () => { + if (inProgress) { + return; + } + + setInProgress(true); + + const t = setTimeout(() => { + setInProgress(false); + clearTimeout(t); + }, 1000); + }; + + return ( + <> + {children} + + {inProgress ? "Copied" : "Copy"} + + + ); +}; diff --git a/src/components/CopyWithTooltip/index.tsx b/src/components/CopyWithTooltip/index.tsx new file mode 100644 index 00000000..a16f97d0 --- /dev/null +++ b/src/components/CopyWithTooltip/index.tsx @@ -0,0 +1,54 @@ +// TODO: move to Stellar Design System components +import React, { useState } from "react"; +import CopyToClipboard from "react-copy-to-clipboard"; + +import "./styles.scss"; + +export enum TooltipPosition { + bottom = "bottom", + right = "right", +} + +interface CopyWithTooltipProps { + copyText: string; + tooltipLabel?: string; + tooltipPosition?: TooltipPosition; + children: React.ReactNode; +} + +export const CopyWithTooltip = ({ + copyText, + tooltipLabel = "Copied", + tooltipPosition = TooltipPosition.bottom, + children, +}: CopyWithTooltipProps) => { + const [isTooltipVisible, setIsTooltipVisible] = useState(false); + + const handleCopyDone = () => { + if (isTooltipVisible) { + return; + } + + setIsTooltipVisible(true); + + const t = setTimeout(() => { + setIsTooltipVisible(false); + clearTimeout(t); + }, 1000); + }; + + return ( + +
+ {children} +
+ {tooltipLabel} +
+
+
+ ); +}; diff --git a/src/components/CopyWithTooltip/styles.scss b/src/components/CopyWithTooltip/styles.scss new file mode 100644 index 00000000..461a288c --- /dev/null +++ b/src/components/CopyWithTooltip/styles.scss @@ -0,0 +1,39 @@ +.CopyWithTooltip { + cursor: pointer; + position: relative; + + .Tooltip { + // Default Tooltip styles (move to global styles) + position: absolute; + top: 2.7rem; + right: -1rem; + max-width: 270px; + border-radius: 0.25rem; + background-color: var(--color-accent); + padding: 1rem 1.5rem; + color: var(--color-background-secondary); + font-size: 0.875rem; + line-height: 1.5rem; + z-index: var(--z-index-tooltip); + cursor: default; + + // Copy Tooltip styles + visibility: hidden; + padding: 0.5rem 1rem; + background-color: var(--color-background-contrast); + + &[data-position="bottom"] { + right: 50%; + transform: translateX(50%); + } + + &[data-position="right"] { + top: 50%; + transform: translate(100%, -50%); + } + + &[data-visible="true"] { + visibility: visible; + } + } +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 00000000..968970f9 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; +import { TextLink, TextLinkVariant, TextButton } from "@stellar/design-system"; +import { Modal } from "components/Modal"; +import { ConfigurationModal } from "components/ConfigurationModal"; +import { useRedux } from "hooks/useRedux"; + +export const Footer = () => { + const [configModalVisible, setConfigModalVisible] = useState(false); + + const { account } = useRedux("account"); + + const handleConfigModalClose = () => { + setConfigModalVisible(false); + }; + + return ( + <> +
+
+
+ + Terms of Service + + + Privacy Policy + +
+ + {account.isAuthenticated && ( +
+ setConfigModalVisible(true)}> + Configuration + +
+ )} +
+
+ + + + + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 00000000..9c45da54 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { ProjectLogo, TextButton } from "@stellar/design-system"; +import { Modal } from "components/Modal"; +import { SignOutModal } from "components/SignOutModal"; +import { useRedux } from "hooks/useRedux"; + +export const Header = () => { + const [modalVisible, setModalVisible] = useState(false); + + const { account } = useRedux("account"); + + const handleCloseModal = () => { + setModalVisible(false); + }; + + return ( +
+
+ + {account.isAuthenticated && ( + setModalVisible(true)}> + Sign out + + )} +
+ + + + +
+ ); +}; diff --git a/src/components/Heading/index.tsx b/src/components/Heading/index.tsx new file mode 100644 index 00000000..42beee0f --- /dev/null +++ b/src/components/Heading/index.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { InfoButtonWithTooltip } from "components/InfoButtonWithTooltip"; +import "./styles.scss"; + +interface HeadingProps extends React.HTMLAttributes { + children: string; + tooltipText?: string | React.ReactNode; +} + +const getHeadingComponent = (Component: string): React.FC => ({ + children, + tooltipText, + ...props +}) => ( + + {children} + {tooltipText && ( + {tooltipText} + )} + +); + +export const Heading1 = getHeadingComponent("h1"); +export const Heading2 = getHeadingComponent("h2"); +export const Heading3 = getHeadingComponent("h3"); +export const Heading4 = getHeadingComponent("h4"); +export const Heading5 = getHeadingComponent("h5"); diff --git a/src/components/Heading/styles.scss b/src/components/Heading/styles.scss new file mode 100644 index 00000000..84a07df7 --- /dev/null +++ b/src/components/Heading/styles.scss @@ -0,0 +1,58 @@ +@mixin base-heading { + font-weight: var(--font-weight-normal); + color: var(--color-text); + margin: 0 0 0.5em; + padding: 0; + position: relative; + + // Tooltip + .InfoButton { + display: inline-block; + vertical-align: middle; + margin-left: 0.5rem; + } + + .InfoButtonTooltip { + right: 50%; + transform: translateX(50%); + margin-right: -1rem; + text-align: left; + } +} + +// Base font size 16px +// 32 / 40 (font size / line height) +h1 { + @include base-heading; + font-size: 2rem; + line-height: 2.5rem; +} + +// 24 / 32 +h2 { + @include base-heading; + font-size: 1.5rem; + line-height: 2rem; +} + +// 20 / 30 +h3 { + @include base-heading; + font-size: 1.25rem; + line-height: 1.875rem; +} + +// 16 / 24 +h4 { + @include base-heading; + font-size: 1rem; + line-height: 1.5rem; + font-weight: var(--font-weight-medium); +} + +// 16 / 24 +h5 { + @include base-heading; + font-size: 1rem; + line-height: 1.5rem; +} diff --git a/src/components/HomeDomainOverrideButtons.tsx b/src/components/HomeDomainOverrideButtons.tsx new file mode 100644 index 00000000..c1f06e7b --- /dev/null +++ b/src/components/HomeDomainOverrideButtons.tsx @@ -0,0 +1,122 @@ +import { useState } from "react"; +import { useDispatch } from "react-redux"; +import { useHistory } from "react-router-dom"; +import { TextLink } from "components/TextLink"; +import { ConfirmAssetAction } from "components/ConfirmAssetAction"; +import { HomeDomainOverrideModal } from "components/HomeDomainOverrideModal"; +import { Modal } from "components/Modal"; +import { IconButton } from "components/IconButton"; +import { + setActiveAssetAction, + resetActiveAssetAction, +} from "ducks/activeAsset"; +import { log } from "helpers/log"; +import { searchParam } from "helpers/searchParam"; +import { ActionStatus, Asset, SearchParams } from "types/types.d"; + +import { ReactComponent as IconEdit } from "assets/icons/edit.svg"; +import { ReactComponent as IconRemove } from "assets/icons/error.svg"; +import { useRedux } from "hooks/useRedux"; +import { Loader } from "@stellar/design-system"; + +export const HomeDomainOverrideButtons = ({ asset }: { asset: Asset }) => { + const [activeModal, setActiveModal] = useState(""); + + const { assetOverrides } = useRedux("assetOverrides"); + + const dispatch = useDispatch(); + const history = useHistory(); + + enum ModalType { + REMOVE_ASSET_OVERRIDE = "REMOVE_ASSET_OVERRIDE", + ASSET_OVERRIDE = "ASSET_OVERRIDE", + } + + const showModal = (modalType: ModalType) => { + setActiveModal(modalType); + + let activeAsset; + + switch (modalType) { + case ModalType.ASSET_OVERRIDE: + // Modal text is set in HomeDomainOverrideModal component + activeAsset = { + assetString: asset.assetString, + title: "", + callback: () => {}, + }; + break; + case ModalType.REMOVE_ASSET_OVERRIDE: + activeAsset = { + assetString: asset.assetString, + title: `Remove ${asset.assetCode} home domain override`, + description: `Asset ${asset.assetCode}’s home domain ${asset.homeDomain} override will be removed. Original home domain will be used, if it exists.`, + callback: handleRemove, + }; + break; + default: + // do nothing + } + + dispatch(setActiveAssetAction(activeAsset)); + }; + + const handleRemove = () => { + history.push( + searchParam.removeKeyPair({ + searchParam: SearchParams.ASSET_OVERRIDES, + itemId: asset.assetString, + }), + ); + log.instruction({ + title: `Asset’s ${asset.assetCode} home domain override \`${asset.homeDomain}\` removed`, + }); + handleCloseModal(); + }; + + const handleCloseModal = () => { + setActiveModal(""); + dispatch(resetActiveAssetAction()); + }; + + if (assetOverrides.status === ActionStatus.PENDING) { + return ; + } + + return ( + <> + {asset.homeDomain ? ( + } + altText="Edit home domain" + onClick={() => showModal(ModalType.ASSET_OVERRIDE)} + /> + ) : ( + showModal(ModalType.ASSET_OVERRIDE)}> + Add home domain + + )} + + {asset.isOverride && ( + } + altText="Remove home domain override" + onClick={() => showModal(ModalType.REMOVE_ASSET_OVERRIDE)} + color="var(--color-error)" + /> + )} + + + {/* Action confirmation */} + {activeModal === ModalType.REMOVE_ASSET_OVERRIDE && ( + + )} + + {/* Override home domain */} + {activeModal === ModalType.ASSET_OVERRIDE && ( + + )} + + + ); +}; diff --git a/src/components/HomeDomainOverrideModal.tsx b/src/components/HomeDomainOverrideModal.tsx new file mode 100644 index 00000000..0dffc217 --- /dev/null +++ b/src/components/HomeDomainOverrideModal.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { useHistory } from "react-router-dom"; +import { + Button, + ButtonVariant, + Heading2, + Input, + InfoBlock, + InfoBlockVariant, + Loader, +} from "@stellar/design-system"; +import { getAssetFromHomeDomain } from "helpers/getAssetFromHomeDomain"; +import { getErrorMessage } from "helpers/getErrorMessage"; +import { getNetworkConfig } from "helpers/getNetworkConfig"; +import { log } from "helpers/log"; +import { searchParam } from "helpers/searchParam"; +import { useRedux } from "hooks/useRedux"; +import { Asset, SearchParams } from "types/types.d"; + +export const HomeDomainOverrideModal = ({ + asset, + onClose, +}: { + asset: Asset; + onClose: () => void; +}) => { + const { settings } = useRedux("settings"); + const history = useHistory(); + + const [homeDomain, setHomeDomain] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [isPending, setIsPending] = useState(false); + + const handleOverride = async () => { + setErrorMessage(""); + setIsPending(true); + + const { assetCode, assetIssuer } = asset; + const networkUrl = getNetworkConfig(settings.pubnet).url; + + try { + const validAsset = await getAssetFromHomeDomain({ + assetCode, + homeDomain, + issuerPublicKey: assetIssuer, + networkUrl, + }); + + if (validAsset.homeDomain) { + history.push( + searchParam.updateKeyPair({ + searchParam: SearchParams.ASSET_OVERRIDES, + itemId: `${asset.assetCode}:${asset.assetIssuer}`, + keyPairs: { homeDomain }, + }), + ); + + onClose(); + } else { + throw new Error( + `Override home domain is the same as ${asset.assetCode} asset home domain`, + ); + } + } catch (e) { + const errorMsg = getErrorMessage(e); + setErrorMessage(errorMsg); + log.error({ title: errorMsg }); + setIsPending(false); + } + }; + + return ( + <> + Override home domain + +
+

{`Asset ${asset.assetCode} currently has ${ + asset.homeDomain || "no" + } home domain.`}

+ + { + setErrorMessage(""); + setHomeDomain(e.target.value); + }} + value={homeDomain} + placeholder="ex: example.com" + /> + + {errorMessage && ( + +

{errorMessage}

+
+ )} +
+ +
+ {isPending && } + + + + +
+ + ); +}; diff --git a/src/components/IconButton/index.tsx b/src/components/IconButton/index.tsx new file mode 100644 index 00000000..1781ea28 --- /dev/null +++ b/src/components/IconButton/index.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import "./styles.scss"; + +type IconButtonProps = { + icon: React.ReactNode; + onClick: () => void; + altText: string; + color?: string; +}; + +export const IconButton = ({ + icon, + onClick, + altText, + color, +}: IconButtonProps) => ( +
+ {icon} +
+); diff --git a/src/components/IconButton/styles.scss b/src/components/IconButton/styles.scss new file mode 100644 index 00000000..daaa6bf0 --- /dev/null +++ b/src/components/IconButton/styles.scss @@ -0,0 +1,14 @@ +.IconButton { + cursor: pointer; + width: 1.25rem; + height: 1.25rem; + margin-left: 0.3rem; + flex-shrink: 0; + fill: #666; + + svg { + width: 100%; + height: 100%; + fill: inherit; + } +} diff --git a/src/components/InfoButtonWithTooltip/index.tsx b/src/components/InfoButtonWithTooltip/index.tsx new file mode 100644 index 00000000..e71bc499 --- /dev/null +++ b/src/components/InfoButtonWithTooltip/index.tsx @@ -0,0 +1,59 @@ +import React, { useState, useEffect, useRef } from "react"; +import { IconInfo } from "@stellar/design-system"; +import "./styles.scss"; + +export const InfoButtonWithTooltip = ({ + children, +}: { + children: string | React.ReactNode; +}) => { + const toggleEl = useRef(null); + const infoEl = useRef(null); + const [isInfoVisible, setIsInfoVisible] = useState(false); + + useEffect(() => { + if (isInfoVisible) { + document.addEventListener("mousedown", handleClickOutside); + } else { + document.removeEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isInfoVisible]); + + const handleClickOutside = (event: MouseEvent) => { + // Do nothing if clicking tooltip itself or link inside the tooltip + if ( + event.target === infoEl?.current || + infoEl?.current?.contains(event.target as Node) + ) { + return; + } + + if (!toggleEl?.current?.contains(event.target as Node)) { + setIsInfoVisible(false); + } + }; + + return ( + <> +
setIsInfoVisible((currentState) => !currentState)} + > + +
+ +
+ {children} +
+ + ); +}; diff --git a/src/components/InfoButtonWithTooltip/styles.scss b/src/components/InfoButtonWithTooltip/styles.scss new file mode 100644 index 00000000..f24ab42b --- /dev/null +++ b/src/components/InfoButtonWithTooltip/styles.scss @@ -0,0 +1,43 @@ +.InfoButton { + cursor: pointer; + width: 1.25rem; + height: 1.25rem; + margin-left: 0.3rem; + flex-shrink: 0; + position: relative; + + svg { + width: 100%; + height: 100%; + // TODO: add info icon color to SDS + fill: #ccc; + position: absolute; + top: 0; + left: 0; + } +} + +.InfoButtonTooltip { + position: absolute; + top: 2.2rem; + right: -0.5rem; + width: 270px; + border-radius: 0.25rem; + background-color: var(--color-accent); + padding: 1rem 1.5rem; + color: var(--color-text-contrast); + font-size: 0.875rem; + line-height: 1.5rem; + z-index: var(--z-index-tooltip); + cursor: default; + visibility: hidden; + + &[data-hidden="false"] { + visibility: visible; + } + + a { + color: inherit; + white-space: nowrap; + } +} diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx new file mode 100644 index 00000000..c90dfdb4 --- /dev/null +++ b/src/components/Input/index.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { InfoButtonWithTooltip } from "components/InfoButtonWithTooltip"; +import "./styles.scss"; + +interface InputProps extends React.InputHTMLAttributes { + id: string; + label?: string; + rightElement?: string; + note?: React.ReactNode; + error?: string; + tooltipText?: string | React.ReactNode; +} + +export const Input = ({ + id, + label, + rightElement, + note, + error, + tooltipText, + ...props +}: InputProps) => ( +
+ {label && ( +
+ + {tooltipText && ( + {tooltipText} + )} +
+ )} +
+
+ +
+ {rightElement &&
{rightElement}
} +
+ {error &&
{error}
} + {note &&
{note}
} +
+); diff --git a/src/components/Input/styles.scss b/src/components/Input/styles.scss new file mode 100644 index 00000000..217925b1 --- /dev/null +++ b/src/components/Input/styles.scss @@ -0,0 +1,83 @@ +@mixin input-note-shared { + margin-top: 1rem; +} + +.Input { + width: 100%; + + .InputLabelWrapper { + position: relative; + display: flex; + align-items: center; + margin-bottom: 0.5rem; + + label { + margin-bottom: 0; + } + + .InfoButton { + margin-left: 0.5rem; + } + + .InfoButtonTooltip { + right: auto; + left: 0; + } + } + + .InputContainer { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + + .InputWrapper { + border: 1px solid var(--color-border-input); + border-radius: 0.125rem; + background-color: var(--color-background-input); + flex: 1; + height: 100%; + + &[data-disabled="true"] { + opacity: 0.5; + } + + input { + padding: 0.55rem 0.75rem; + font-size: 1rem; + line-height: 1.5rem; + color: var(--color-text-input); + border-radius: 0.125rem; + border: none; + background-color: transparent; + width: 100%; + min-width: 0; + + &::placeholder { + color: var(--color-text-input-placeholder); + } + } + } + + .InputRightElement { + margin-left: 1rem; + font-size: 1rem; + line-height: 1.5rem; + color: var(--color-text); + } + } + + .InputError { + @include input-note-shared; + font-size: 1rem; + line-height: 1.5rem; + color: var(--color-error); + } + + .InputNote { + @include input-note-shared; + font-size: 0.9rem; + line-height: 1.4rem; + color: var(--color-note); + } +} diff --git a/src/components/LogItem/index.tsx b/src/components/LogItem/index.tsx new file mode 100644 index 00000000..3b56ff97 --- /dev/null +++ b/src/components/LogItem/index.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from "react"; +import ReactJson from "react-json-view"; +import marked from "marked"; +import { ReactComponent as IconArrowLeft } from "assets/icons/arrow-left.svg"; +import { ReactComponent as IconArrowRight } from "assets/icons/arrow-right.svg"; +import { ReactComponent as IconBubble } from "assets/icons/bubble.svg"; +import { ReactComponent as IconError } from "assets/icons/error.svg"; +import { sanitizeHtml } from "helpers/sanitizeHtml"; +import { LogType } from "types/types.d"; +import "./styles.scss"; + +marked.setOptions({ + gfm: false, +}); + +const LogItemIcon = { + instruction: , + error: , + request: , + response: , +}; + +interface LogItemProps { + title: string; + variant: LogType; + body?: string | object; +} + +const theme = { + light: { + base00: "#fff", + base01: "#fff", + base02: "#fff", + base03: "#000", + base04: "#000", + base05: "#000", + base06: "#000", + base07: "#000", + base08: "#000", + base09: "#000", + base0A: "#000", + base0B: "#000", + base0C: "#000", + base0D: "#000", + base0E: "#000", + base0F: "#000", + }, + dark: { + base00: "#292d3e", + base01: "#292d3e", + base02: "#292d3e", + base03: "#fbfaf7", + base04: "#fbfaf7", + base05: "#fbfaf7", + base06: "#fbfaf7", + base07: "#fbfaf7", + base08: "#fbfaf7", + base09: "#fbfaf7", + base0A: "#fbfaf7", + base0B: "#fbfaf7", + base0C: "#fbfaf7", + base0D: "#fbfaf7", + base0E: "#fbfaf7", + base0F: "#fbfaf7", + }, +}; + +export const LogItem = ({ title, variant, body }: LogItemProps) => { + const [isFadeReady, setIsFadeReady] = useState(false); + + useEffect(() => { + const t = setTimeout(() => { + setIsFadeReady(true); + clearTimeout(t); + }, 150); + }, []); + + const bodyParsed = body ? JSON.parse(`${body}`) : body; + + return ( +
+
+
{LogItemIcon[variant]}
+
{sanitizeHtml(marked(title))}
+
+ {bodyParsed && ( +
+ {typeof bodyParsed === "object" ? ( + + ) : ( + bodyParsed + )} +
+ )} +
+ ); +}; diff --git a/src/components/LogItem/styles.scss b/src/components/LogItem/styles.scss new file mode 100644 index 00000000..29a4435f --- /dev/null +++ b/src/components/LogItem/styles.scss @@ -0,0 +1,117 @@ +.LogItem { + max-width: 100%; + border-radius: 0.25rem; + overflow: hidden; + opacity: 0; + transition: all 300ms; + + &.open { + opacity: 1; + } + + .LogItemHeader { + display: flex; + align-items: flex-start; + padding: 1rem; + } + + .LogItemIcon { + width: 1rem; + height: 1.25rem; + margin-right: 0.5rem; + flex-shrink: 0; + flex-grow: 0; + + svg { + width: 100%; + height: 100%; + } + } + + .LogItemTitle { + font-size: 1rem; + line-height: 1.25rem; + word-break: break-word; + } + + .LogItemBody { + padding: 1rem; + font-weight: var(--font-weight-light); + font-family: var(--font-family-monospace); + font-size: 0.875rem; + line-height: 1.375rem; + word-break: break-word; + } + + // Light box + &.instruction, + &.error { + width: fit-content; + background-color: var(--color-background-main); + border: 1px solid var(--color-border-input); + box-shadow: 0 0.25rem 0.5rem -0.25rem rgba(0, 0, 0, 0.08); + color: var(--color-text); + + .LogItemBody { + border-top: 1px solid rgba(0, 0, 0, 0.1); + color: var(--color-text); + } + } + + // Dark box + &.request, + &.response { + width: 100%; + background-color: var(--color-background-contrast); + color: var(--color-text-contrast); + + .LogItemTitle { + font-weight: var(--font-weight-medium); + } + + .LogItemBody { + border-top: 1px solid rgba(255, 255, 255, 0.1); + // TODO: add color to SDS + color: #fbfaf7; + } + + code { + // TODO: add color to SDS + color: #ffdd96; + background-color: rgba(255, 221, 150, 0.08); + border-color: rgba(255, 221, 150, 0.16); + } + } + + // Instruction + &.instruction { + .LogItemIcon svg { + // TODO: add color to SDS + fill: #20bf6b; + } + } + + // Error + &.error { + .LogItemIcon svg { + // TODO: add color to SDS + fill: #eb3b5a; + } + } + + // Request + &.request { + .LogItemIcon svg { + // TODO: add color to SDS + fill: #ffdd96; + } + } + + // Response + &.response { + .LogItemIcon svg { + // TODO: add color to SDS + fill: #abcc7d; + } + } +} diff --git a/src/components/Logs.tsx b/src/components/Logs.tsx new file mode 100644 index 00000000..33e60b0f --- /dev/null +++ b/src/components/Logs.tsx @@ -0,0 +1,134 @@ +import { useEffect } from "react"; +import { useDispatch } from "react-redux"; +import { TextButton } from "@stellar/design-system"; + +import { LogItem } from "components/LogItem"; +import { LOG_MESSAGE_EVENT } from "constants/settings"; +import { clearLogsAction, addLogAction } from "ducks/logs"; +import { useRedux } from "hooks/useRedux"; +import { LogItemProps } from "types/types.d"; + +export const Logs = () => { + const { account, logs } = useRedux("account", "logs"); + const dispatch = useDispatch(); + + useEffect(() => { + const onLogEventMessage = (e: any) => { + const { timestamp, type, title, body } = e.detail; + + dispatch( + addLogAction({ + timestamp, + type, + title, + body: JSON.stringify(body), + }), + ); + }; + + document.addEventListener(LOG_MESSAGE_EVENT, onLogEventMessage); + + return document.removeEventListener( + LOG_MESSAGE_EVENT, + onLogEventMessage, + true, + ); + }, [dispatch]); + + const logsToMarkdown = (logItems: LogItemProps[]) => { + const heading = `# Stellar Demo Wallet logs\n\n`; + const date = `${new Date()}\n\n`; + const url = `[URL](${window.location.toString()})\n\n`; + const divider = `---\n\n`; + const contentHeader = `${heading}${date}${url}${divider}`; + + return logItems.reduce((result, log, index) => { + const isLastItem = index === logItems.length - 1; + let content = `**${log.type}:** ${log.title}\n`; + let body = log.body ? JSON.parse(`${log.body}`) : null; + + if (body) { + body = typeof body === "string" ? body : JSON.stringify(body, null, 2); + content += `\n\`\`\`javascript\n${body}\n\`\`\`\n`; + } + + if (!isLastItem) { + content += "\n"; + } + + return `${result}${content}`; + }, contentHeader); + }; + + const handleDownload = () => { + if (!logs.items.length) { + return; + } + + const filename = `stellar-dw-logs-${Date.now()}.md`; + const content = logsToMarkdown(logs.items); + const element = document.createElement("a"); + + element.setAttribute( + "href", + `data:text/plain;charset=utf-8,${encodeURIComponent(content)}`, + ); + element.setAttribute("download", filename); + element.style.display = "none"; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + }; + + if (!account.isAuthenticated) { + return ( +
+
+
+ Operation logs will appear here once a transaction begins +
+
+
+ ); + } + + return ( +
+
+
+
+
+ {logs.items.length ? ( + logs.items.map((log: LogItemProps) => ( + + )) + ) : ( +

No logs to show

+ )} +
+
+
+
+ +
+
+ + Download logs + + + dispatch(clearLogsAction())} + disabled={!logs.items.length} + > + Clear logs + +
+
+
+ ); +}; diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx new file mode 100644 index 00000000..8e90ccba --- /dev/null +++ b/src/components/Modal/index.tsx @@ -0,0 +1,48 @@ +import React, { useEffect } from "react"; +import ReactDOM from "react-dom"; +import { IconClose } from "@stellar/design-system"; + +import "./styles.scss"; + +const MODAL_OPEN_CLASS_NAME = "modal-open"; + +interface ModalProps { + visible: boolean; + onClose: () => void; + children: React.ReactNode; + disableWindowScrollWhenOpened?: boolean; +} + +export const Modal = ({ + visible, + onClose, + disableWindowScrollWhenOpened = false, + children, +}: ModalProps) => { + const parent = document.getElementById("app-wrapper"); + + useEffect(() => { + if (disableWindowScrollWhenOpened && visible) { + document.body.classList.add(MODAL_OPEN_CLASS_NAME); + } else { + document.body.classList.remove(MODAL_OPEN_CLASS_NAME); + } + }, [disableWindowScrollWhenOpened, visible]); + + if (!parent || !visible) { + return null; + } + + return ReactDOM.createPortal( +
+
+
{children}
+ +
+
+
, + parent, + ); +}; diff --git a/src/components/Modal/styles.scss b/src/components/Modal/styles.scss new file mode 100644 index 00000000..b5352d7f --- /dev/null +++ b/src/components/Modal/styles.scss @@ -0,0 +1,102 @@ +.ModalWrapper { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 50%; + z-index: var(--z-index-modal); + min-width: calc(var(--size-min-window) / 2); + min-height: 50vh; + overflow: hidden; + + .Modal { + position: absolute; + width: 80%; + max-width: 600px; + background-color: var(--color-background-main); + border-radius: 0; + z-index: calc(var(--z-index-modal) + 1); + padding: 4.5rem 1.5rem 2rem; + overflow: hidden; + box-shadow: 0 1.5rem 3rem -1.5rem rgba(0, 0, 0, 0.16); + margin-top: 3rem; + top: 35%; + left: 50%; + transform: translate(-50%, -35%); + border-radius: 0.5rem; + + .ModalContent { + overflow-y: auto; + max-height: 70vh; + + .ModalHeading { + margin-bottom: 1.5rem; + text-align: center; + } + + .ModalMessage { + margin-top: 1.5rem; + word-break: break-word; + } + + .ModalButtonsFooter { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + width: 100%; + margin-top: 1.5rem; + + & > *:not(:first-child) { + margin-left: 0.5rem; + } + } + + .ModalBody { + & > *:not(:last-child) { + margin-bottom: 1rem; + } + + .InfoBlock:not(:last-child) { + margin-bottom: 1.5rem; + } + } + } + + .ModalCloseButton { + width: 3rem; + height: 3rem; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + padding: 0; + margin: 0; + position: absolute; + top: 1rem; + right: 0.75rem; + cursor: pointer; + + &:hover { + opacity: 0.8; + } + + svg { + width: 1rem; + height: 1rem; + fill: var(--color-link); + } + } + } + + .ModalBackground { + position: absolute; + background-color: #000; + opacity: 0.24; + top: 0; + bottom: 0; + left: 0; + right: 0; + } +} diff --git a/src/components/PageContent.tsx b/src/components/PageContent.tsx new file mode 100644 index 00000000..3b26de87 --- /dev/null +++ b/src/components/PageContent.tsx @@ -0,0 +1,5 @@ +import React from "react"; + +export const PageContent = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); diff --git a/src/components/PrivateRoute.tsx b/src/components/PrivateRoute.tsx new file mode 100644 index 00000000..70fbb398 --- /dev/null +++ b/src/components/PrivateRoute.tsx @@ -0,0 +1,25 @@ +import { Route, Redirect, RouteProps, useLocation } from "react-router-dom"; +import { useRedux } from "hooks/useRedux"; + +export const PrivateRoute = ({ children, ...rest }: RouteProps) => { + const { account } = useRedux("account"); + const location = useLocation(); + + return ( + + account.isAuthenticated ? ( + children + ) : ( + + ) + } + /> + ); +}; diff --git a/src/components/SendPayment.tsx b/src/components/SendPayment.tsx new file mode 100644 index 00000000..a916dfeb --- /dev/null +++ b/src/components/SendPayment.tsx @@ -0,0 +1,163 @@ +import { useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; +import { + Button, + Heading2, + InfoBlock, + Input, + Loader, +} from "@stellar/design-system"; +import { TextLink } from "components/TextLink"; +import { DataProvider } from "@stellar/wallet-sdk"; +import { StrKey } from "stellar-sdk"; + +import { fetchAccountAction } from "ducks/account"; +import { resetActiveAssetAction } from "ducks/activeAsset"; +import { sendPaymentAction, resetSendPaymentAction } from "ducks/sendPayment"; +import { getNetworkConfig } from "helpers/getNetworkConfig"; +import { useRedux } from "hooks/useRedux"; +import { ActionStatus, Asset, AssetType } from "types/types.d"; + +export const SendPayment = ({ + asset, + onClose, +}: { + asset?: Asset; + onClose: () => void; +}) => { + const { account, sendPayment, settings } = useRedux( + "account", + "sendPayment", + "settings", + ); + const { data, secretKey } = account; + const dispatch = useDispatch(); + + // Form data + const [destination, setDestination] = useState(""); + const [amount, setAmount] = useState(""); + const [assetCode, setAssetCode] = useState(asset?.assetCode); + const [assetIssuer, setAssetIssuer] = useState(asset?.assetIssuer || ""); + const [isDestinationFunded, setIsDestinationFunded] = useState(true); + + const resetFormState = () => { + setDestination(""); + setAmount(""); + setAssetCode(""); + setAssetIssuer(""); + setIsDestinationFunded(true); + }; + + useEffect(() => { + if (sendPayment.status === ActionStatus.SUCCESS && data?.id) { + dispatch( + fetchAccountAction({ + publicKey: data.id, + secretKey, + }), + ); + dispatch(resetSendPaymentAction()); + dispatch(resetActiveAssetAction()); + resetFormState(); + onClose(); + } + }, [sendPayment.status, secretKey, data?.id, dispatch, onClose]); + + const checkAndSetIsDestinationFunded = async () => { + if (!destination || !StrKey.isValidEd25519PublicKey(destination)) { + return; + } + + const dataProvider = new DataProvider({ + serverUrl: getNetworkConfig(settings.pubnet).url, + accountOrKey: destination, + networkPassphrase: getNetworkConfig(settings.pubnet).network, + }); + + setIsDestinationFunded(await dataProvider.isAccountFunded()); + }; + + const handleSubmit = () => { + if (data?.id) { + const params = { + destination, + isDestinationFunded, + amount, + assetCode, + assetIssuer, + publicKey: data.id, + }; + + dispatch(sendPaymentAction(params)); + } + }; + + return ( + <> + Send payment + +
+ setDestination(e.target.value)} + onBlur={() => { + checkAndSetIsDestinationFunded(); + }} + /> + setAmount(e.target.value)} + /> + setAssetCode(e.target.value)} + /> + {asset?.assetType !== AssetType.NATIVE && ( + setAssetIssuer(e.target.value)} + /> + )} + + {!isDestinationFunded && ( + + The destination account doesn’t exist. A create account operation + will be used to create this account.{" "} + + Learn more about account creation + + + )} +
+ + {sendPayment.errorString && ( +
+

{sendPayment.errorString}

+
+ )} + +
+ {sendPayment.status === ActionStatus.PENDING && } + + +
+ + ); +}; diff --git a/src/components/Sep31Send.tsx b/src/components/Sep31Send.tsx new file mode 100644 index 00000000..0c99562d --- /dev/null +++ b/src/components/Sep31Send.tsx @@ -0,0 +1,125 @@ +import React, { useState, useEffect } from "react"; +import { useDispatch } from "react-redux"; +import { Button, Input } from "@stellar/design-system"; +import { Heading2, Heading3 } from "components/Heading"; +import { TextLink } from "components/TextLink"; +import { Modal } from "components/Modal"; +import { fetchAccountAction } from "ducks/account"; +import { resetActiveAssetAction } from "ducks/activeAsset"; +import { + resetSep31SendAction, + submitSep31SendTransactionAction, +} from "ducks/sep31Send"; +import { capitalizeString } from "helpers/capitalizeString"; +import { useRedux } from "hooks/useRedux"; +import { ActionStatus } from "types/types.d"; + +export const Sep31Send = () => { + const { account, sep31Send } = useRedux("account", "sep31Send"); + const [formData, setFormData] = useState({}); + + const dispatch = useDispatch(); + + useEffect(() => { + if (sep31Send.status === ActionStatus.SUCCESS) { + if (account.data?.id) { + dispatch( + fetchAccountAction({ + publicKey: account.data.id, + secretKey: account.secretKey, + }), + ); + dispatch(resetSep31SendAction()); + } + } + }, [sep31Send.status, account.data?.id, account.secretKey, dispatch]); + + const handleChange = (event: React.ChangeEvent) => { + const { id, value } = event.target; + const [section, field] = id.split("#"); + + const updatedState = { + ...formData, + [section]: { + ...(formData[section] || {}), + [field]: value, + }, + }; + + setFormData(updatedState); + }; + + const handleSubmit = ( + event: React.MouseEvent, + ) => { + event.preventDefault(); + dispatch(submitSep31SendTransactionAction({ ...formData })); + }; + + const handleClose = () => { + dispatch(resetSep31SendAction()); + dispatch(resetActiveAssetAction()); + }; + + if (sep31Send.status === ActionStatus.NEEDS_INPUT) { + const { data } = sep31Send; + const { transaction, sender, receiver } = data.fields; + + const allFields = { + amount: { + amount: { + description: "amount to send", + }, + }, + ...(sender ? { sender } : {}), + ...(receiver ? { receiver } : {}), + ...(transaction ? { transaction } : {}), + }; + + return ( + + + These are the fields the receiving anchor requires. The sending + client obtains them from the /customer endpoint.{" "} + + Learn more + + + } + > + Sender and receiver info + + +
+ {Object.entries(allFields).map(([sectionTitle, sectionItems]) => ( +
+ {capitalizeString(sectionTitle)} + {Object.entries(sectionItems || {}).map(([id, input]) => ( + // TODO: if input.choices, render Select + + ))} +
+ ))} +
+ +
+ +
+
+ ); + } + + return null; +}; diff --git a/src/components/SettingsHandler.tsx b/src/components/SettingsHandler.tsx new file mode 100644 index 00000000..60b5c970 --- /dev/null +++ b/src/components/SettingsHandler.tsx @@ -0,0 +1,123 @@ +import React, { useEffect } from "react"; +import { useLocation, useHistory } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { Keypair } from "stellar-sdk"; +import { fetchAccountAction } from "ducks/account"; +import { fetchClaimableBalancesAction } from "ducks/claimableBalances"; +import { updateSettingsAction } from "ducks/settings"; +import { getErrorMessage } from "helpers/getErrorMessage"; +import { log } from "helpers/log"; +import { useRedux } from "hooks/useRedux"; +import { ActionStatus, SearchParams } from "types/types.d"; + +export const SettingsHandler = ({ + children, +}: { + children: React.ReactNode; +}) => { + const { account } = useRedux("account"); + + const dispatch = useDispatch(); + const history = useHistory(); + const location = useLocation(); + + const queryParams = new URLSearchParams(location.search); + const pubnetParam = queryParams.get(SearchParams.PUBNET); + const secretKeyParam = queryParams.get(SearchParams.SECRET_KEY); + const untrustedAssetsParam = queryParams.get(SearchParams.UNTRUSTED_ASSETS); + const assetOverridesParam = queryParams.get(SearchParams.ASSET_OVERRIDES); + const claimableBalanceSupportedParam = queryParams.get( + SearchParams.CLAIMABLE_BALANCE_SUPPORTED, + ); + + // Set network param (pubnet=true) + useEffect(() => { + dispatch( + updateSettingsAction({ + [SearchParams.PUBNET]: pubnetParam === "true", + }), + ); + }, [pubnetParam, dispatch]); + + // Set secret key param (secretKey=[SECRET_KEY]) and fetch account info + // This will handle both: secret key submitted on Demo Wallet and directly + // from the URL + useEffect(() => { + dispatch( + updateSettingsAction({ + [SearchParams.SECRET_KEY]: secretKeyParam || "", + }), + ); + + // TODO: validate secret key + if (secretKeyParam) { + try { + const keypair = Keypair.fromSecret(secretKeyParam); + dispatch( + fetchAccountAction({ + publicKey: keypair.publicKey(), + secretKey: keypair.secret(), + }), + ); + + dispatch( + fetchClaimableBalancesAction({ publicKey: keypair.publicKey() }), + ); + } catch (error) { + log.error({ + title: "Fetch account error", + body: getErrorMessage(error), + }); + } + } + }, [secretKeyParam, dispatch]); + + // Untrusted assets + useEffect(() => { + const cleanedAssets = untrustedAssetsParam + ?.split(",") + .reduce( + (unique: string[], item: string) => + unique.includes(item) ? unique : [...unique, item], + [], + ) + .join(","); + + dispatch( + updateSettingsAction({ + [SearchParams.UNTRUSTED_ASSETS]: cleanedAssets || "", + }), + ); + }, [untrustedAssetsParam, dispatch]); + + // Asset overrides + useEffect(() => { + dispatch( + updateSettingsAction({ + [SearchParams.ASSET_OVERRIDES]: assetOverridesParam || "", + }), + ); + }, [assetOverridesParam, dispatch]); + + // Claimabable balance supported + useEffect(() => { + dispatch( + updateSettingsAction({ + [SearchParams.CLAIMABLE_BALANCE_SUPPORTED]: + claimableBalanceSupportedParam === "true", + }), + ); + }, [claimableBalanceSupportedParam, dispatch]); + + // Go to /account page if fetching account was success + useEffect(() => { + if (account.status === ActionStatus.SUCCESS) { + history.push({ + pathname: "/account", + search: history.location.search, + }); + } + }, [account.status, history]); + + return <>{children}; +}; diff --git a/src/components/SignOutModal.tsx b/src/components/SignOutModal.tsx new file mode 100644 index 00000000..b19dbf9d --- /dev/null +++ b/src/components/SignOutModal.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from "react"; +import { useHistory } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { + Button, + ButtonVariant, + TextButton, + IconCopy, + InfoBlock, + InfoBlockVariant, +} from "@stellar/design-system"; +import { CopyWithTooltip, TooltipPosition } from "components/CopyWithTooltip"; +import { resetStoreAction } from "config/store"; +import { getCurrentSessionParams } from "helpers/getCurrentSessionParams"; +import { SearchParams, StringObject } from "types/types.d"; + +export const SignOutModal = ({ onClose }: { onClose: () => void }) => { + const [sessionParams, setSessionParams] = useState([]); + + const dispatch = useDispatch(); + const history = useHistory(); + + useEffect(() => { + setSessionParams(getCurrentSessionParams()); + }, []); + + const handleSignOut = () => { + dispatch(resetStoreAction()); + history.push({ + pathname: "/", + }); + onClose(); + }; + + const getMessageText = () => { + const paramText: StringObject = { + [SearchParams.ASSET_OVERRIDES]: "home domain overrides", + [SearchParams.UNTRUSTED_ASSETS]: "untrusted assets", + [SearchParams.CLAIMABLE_BALANCE_SUPPORTED]: "claimable balance supported", + }; + + return sessionParams.map((s) => paramText[s]).join(", "); + }; + + return ( + <> +
+

+ You can reload the account using your secret key or press back in your + browser to sign back in. +

+ + {sessionParams.length > 0 && ( + +

+ {`You have session data (${getMessageText()}) that will be lost when you sign out. You can copy the URL to save it.`} +

+
+ + }>Copy URL + +
+
+ )} +
+ +
+ + +
+ + ); +}; diff --git a/src/components/TextButton/index.tsx b/src/components/TextButton/index.tsx new file mode 100644 index 00000000..0ec4e6e2 --- /dev/null +++ b/src/components/TextButton/index.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { InfoButtonWithTooltip } from "components/InfoButtonWithTooltip"; +import "./styles.scss"; + +export enum TextButtonVariant { + primary = "primary", + secondary = "secondary", +} + +interface TextButtonProps + extends React.ButtonHTMLAttributes { + icon?: React.ReactNode; + variant?: TextButtonVariant; + tooltipText?: string | React.ReactNode; + children: string; +} + +export const TextButton: React.FC = ({ + icon, + variant = TextButtonVariant.primary, + tooltipText, + children, + ...props +}) => ( +
+ + {tooltipText && ( + {tooltipText} + )} +
+); diff --git a/src/components/TextButton/styles.scss b/src/components/TextButton/styles.scss new file mode 100644 index 00000000..6864787b --- /dev/null +++ b/src/components/TextButton/styles.scss @@ -0,0 +1,58 @@ +.TextButtonWrapper { + display: flex; + align-items: center; + position: relative; +} + +.TextButton { + font-size: 1rem; + font-family: var(--font-family-base); + line-height: 1.75rem; + padding: 0.5rem 0.2rem; + font-weight: var(--font-weight-medium); + color: var(--color-accent); + background-color: transparent; + border: none; + cursor: pointer; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + &[data-variant="primary"]:hover { + opacity: 0.7; + } + + &[data-variant="secondary"] { + font-weight: var(--font-weight-normal); + color: var(--color-text); + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + .TextButtonIcon { + display: block; + width: 1rem; + height: 1rem; + margin-bottom: 0.3rem; + margin-right: 0.75rem; + position: relative; + + svg { + fill: var(--color-accent); + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + } + } +} diff --git a/src/components/TextLink/index.tsx b/src/components/TextLink/index.tsx new file mode 100644 index 00000000..86199221 --- /dev/null +++ b/src/components/TextLink/index.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import "./styles.scss"; + +export enum TextLinkVariant { + primary = "primary", + secondary = "secondary", +} + +interface TextLinkProps extends React.AnchorHTMLAttributes { + children: string; + variant?: TextLinkVariant; + isExternal?: boolean; +} + +export const TextLink: React.FC = ({ + variant = TextLinkVariant.primary, + children, + isExternal, + ...props +}) => ( + + {children} + +); diff --git a/src/components/TextLink/styles.scss b/src/components/TextLink/styles.scss new file mode 100644 index 00000000..61234004 --- /dev/null +++ b/src/components/TextLink/styles.scss @@ -0,0 +1,24 @@ +.TextLink { + font-weight: var(--font-weight-normal); + color: var(--color-text); + text-decoration: underline; + cursor: pointer; + + &:hover { + text-decoration: none; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + &[data-variant="secondary"] { + color: var(--color-link); + text-decoration: none; + + &:hover { + opacity: 0.7; + } + } +} diff --git a/src/components/ToastBanner/index.tsx b/src/components/ToastBanner/index.tsx new file mode 100644 index 00000000..5d20c40d --- /dev/null +++ b/src/components/ToastBanner/index.tsx @@ -0,0 +1,50 @@ +import React, { useLayoutEffect, useState } from "react"; +import ReactDOM from "react-dom"; +import "./styles.scss"; + +interface ToastBannerProps { + parentId: string; + visible: boolean; + children: React.ReactNode; +} + +export const ToastBanner = ({ + parentId, + visible, + children, +}: ToastBannerProps) => { + const parent = document.getElementById(parentId); + const [isVisible, setIsVisible] = useState(visible); + const [isFadeReady, setIsFadeReady] = useState(false); + + useLayoutEffect(() => { + if (visible) { + setIsVisible(true); + + setTimeout(() => { + setIsFadeReady(true); + }, 150); + } else { + // Add a slight delay when closing for better UX + const t = setTimeout(() => { + setIsFadeReady(false); + clearTimeout(t); + + setTimeout(() => { + setIsVisible(false); + }, 400); + }, 600); + } + }, [visible]); + + if (!parent || !isVisible) { + return null; + } + + return ReactDOM.createPortal( +
+
{children}
+
, + parent, + ); +}; diff --git a/src/components/ToastBanner/styles.scss b/src/components/ToastBanner/styles.scss new file mode 100644 index 00000000..9b993772 --- /dev/null +++ b/src/components/ToastBanner/styles.scss @@ -0,0 +1,24 @@ +.ToastBanner { + --loader-color: var(--color-text-contrast); + + background-color: var(--color-background-contrast); + color: var(--color-text-contrast); + position: absolute; + top: 0; + left: 0; + width: 50%; + z-index: calc(var(--z-index-modal) + 3); + opacity: 0; + transition: all 300ms; + + &.open { + opacity: 1; + } + + .Inset { + min-height: 3.5rem; + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/src/components/Toggle/index.tsx b/src/components/Toggle/index.tsx new file mode 100644 index 00000000..40731a2d --- /dev/null +++ b/src/components/Toggle/index.tsx @@ -0,0 +1,30 @@ +import React, { useEffect, useState } from "react"; +import "./styles.scss"; + +interface ToggleProps extends React.InputHTMLAttributes { + id: string; + checked: boolean; + onChange: () => void; +} + +export const Toggle = ({ id, checked, onChange }: ToggleProps) => { + const [checkedValue, setCheckedValue] = useState(checked); + + useEffect(() => { + setCheckedValue(checked); + }, [checked]); + + return ( +