From 0204aa2001af25da01ba61aed32f36eac47079a1 Mon Sep 17 00:00:00 2001 From: Chi Kei Chan Date: Tue, 19 Sep 2017 21:18:36 -0700 Subject: [PATCH] Add Add Token UI; Add Fuzzy search for tokens --- package.json | 5 +- ui/app/add-token.js | 362 +++++++++++++-------- ui/app/css/itcss/components/add-token.scss | 173 ++++++++++ ui/app/css/itcss/components/index.scss | 2 + yarn.lock | 74 ++++- 5 files changed, 467 insertions(+), 149 deletions(-) create mode 100644 ui/app/css/itcss/components/add-token.scss diff --git a/package.json b/package.json index b615cab20..c6bd437f1 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "end-of-stream": "^1.1.0", "ensnare": "^1.0.0", "eth-bin-to-ops": "^1.0.1", - "eth-contract-metadata": "^1.1.4", + "eth-contract-metadata": "^1.1.5", "eth-hd-keyring": "^1.1.1", "eth-json-rpc-filters": "^1.1.0", "eth-phishing-detect": "^1.1.4", @@ -93,6 +93,7 @@ "extensionizer": "^1.0.0", "fast-json-patch": "^2.0.4", "fast-levenshtein": "^2.0.6", + "fuse.js": "^3.1.0", "gulp": "github:gulpjs/gulp#4.0", "gulp-autoprefixer": "^4.0.0", "gulp-eslint": "^4.0.0", @@ -212,8 +213,8 @@ "react-addons-test-utils": "^15.5.1", "react-test-renderer": "^15.5.4", "react-testutils-additions": "^15.2.0", - "stylelint-config-standard": "^17.0.0", "sinon": "^3.2.0", + "stylelint-config-standard": "^17.0.0", "tape": "^4.5.1", "testem": "^1.10.3", "uglifyify": "^4.0.2", diff --git a/ui/app/add-token.js b/ui/app/add-token.js index 4374ee586..dbba8e4f1 100644 --- a/ui/app/add-token.js +++ b/ui/app/add-token.js @@ -2,8 +2,20 @@ const inherits = require('util').inherits const Component = require('react').Component const h = require('react-hyperscript') const connect = require('react-redux').connect -const actions = require('./actions') -const Tooltip = require('./components/tooltip.js') +const Fuse = require('fuse.js') +const contractMap = require('eth-contract-metadata') +const contractList = Object.entries(contractMap).map(([ _, tokenData]) => tokenData) +const fuse = new Fuse(contractList, { + shouldSort: true, + threshold: 0.3, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: ['address', 'name', 'symbol'], +}) +// const actions = require('./actions') +// const Tooltip = require('./components/tooltip.js') const ethUtil = require('ethereumjs-util') @@ -24,146 +36,232 @@ function mapStateToProps (state) { inherits(AddTokenScreen, Component) function AddTokenScreen () { this.state = { - warning: null, - address: null, - symbol: 'TOKEN', - decimals: 18, + // warning: null, + // address: null, + // symbol: 'TOKEN', + // decimals: 18, + searchQuery: '', + isCollapsed: true, } Component.call(this) } -AddTokenScreen.prototype.render = function () { - const state = this.state - const props = this.props - const { warning, symbol, decimals } = state - - return ( - h('.flex-column.flex-grow', [ - - // subtitle and nav - h('.section-title.flex-row.flex-center', [ - h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { - onClick: (event) => { - props.dispatch(actions.goHome()) - }, - }), - h('h2.page-subtitle', 'Add Token'), +AddTokenScreen.prototype.renderCustomForm = function () { + return !this.state.isCollapsed && ( + h('div.add-token__add-custom-form', [ + h('div.add-token__add-custom-field', [ + h('div.add-token__add-custom-label', 'Token Address'), + h('input.add-token__add-custom-input', { type: 'text' }), ]), - - h('.error', { - style: { - display: warning ? 'block' : 'none', - padding: '0 20px', - textAlign: 'center', - }, - }, warning), - - // conf view - h('.flex-column.flex-justify-center.flex-grow.select-none', [ - h('.flex-space-around', { - style: { - padding: '20px', - }, - }, [ - - h('div', [ - h(Tooltip, { - position: 'top', - title: 'The contract of the actual token contract. Click for more info.', - }, [ - h('a', { - style: { fontWeight: 'bold', paddingRight: '10px'}, - href: 'https://consensyssupport.happyfox.com/staff/kb/article/24-what-is-a-token-contract-address', - target: '_blank', - }, [ - h('span', 'Token Contract Address '), - h('i.fa.fa-question-circle'), - ]), - ]), - ]), - - h('section.flex-row.flex-center', [ - h('input#token-address', { - name: 'address', - placeholder: 'Token Contract Address', - onChange: this.tokenAddressDidChange.bind(this), - style: { - width: 'inherit', - flex: '1 0 auto', - height: '30px', - margin: '8px', - }, - }), - ]), - - h('div', [ - h('span', { - style: { fontWeight: 'bold', paddingRight: '10px'}, - }, 'Token Symbol'), - ]), - - h('div', { style: {display: 'flex'} }, [ - h('input#token_symbol', { - placeholder: `Like "ETH"`, - value: symbol, - style: { - width: 'inherit', - flex: '1 0 auto', - height: '30px', - margin: '8px', - }, - onChange: (event) => { - var element = event.target - var symbol = element.value - this.setState({ symbol }) - }, - }), - ]), - - h('div', [ - h('span', { - style: { fontWeight: 'bold', paddingRight: '10px'}, - }, 'Decimals of Precision'), - ]), - - h('div', { style: {display: 'flex'} }, [ - h('input#token_decimals', { - value: decimals, - type: 'number', - min: 0, - max: 36, - style: { - width: 'inherit', - flex: '1 0 auto', - height: '30px', - margin: '8px', - }, - onChange: (event) => { - var element = event.target - var decimals = element.value.trim() - this.setState({ decimals }) - }, - }), - ]), - - h('button', { - style: { - alignSelf: 'center', - }, - onClick: (event) => { - const valid = this.validateInputs() - if (!valid) return - - const { address, symbol, decimals } = this.state - this.props.dispatch(actions.addToken(address.trim(), symbol.trim(), decimals)) - }, - }, 'Add'), - ]), + h('div.add-token__add-custom-field', [ + h('div.add-token__add-custom-label', 'Token Symbol'), + h('input.add-token__add-custom-input', { type: 'text', disabled: true }), + ]), + h('div.add-token__add-custom-field', [ + h('div.add-token__add-custom-label', 'Decimals of Precision'), + h('input.add-token__add-custom-input', { type: 'text', disabled: true }), ]), ]) ) } +AddTokenScreen.prototype.renderTokenList = function () { + const { searchQuery = '' } = this.state + const results = searchQuery + ? fuse.search(searchQuery) || [] + : contractList + + return Array(6).fill(undefined) + .map((_, i) => { + const { logo, symbol, name } = results[i] || {} + console.log({ i, logo, symbol, name }) + return Boolean(logo || symbol || name) && ( + h('div.add-token__token-wrapper', [ + h('div.add-token__token-icon', { + style: { + backgroundImage: `url(images/contract/${logo})`, + }, + }), + h('div.add-token__token-data', [ + h('div.add-token__token-symbol', symbol), + h('div.add-token__token-name', name), + ]), + ]) + ) + }) +} + +AddTokenScreen.prototype.render = function () { + const { isCollapsed } = this.state + + return ( + h('div.add-token', [ + h('div.add-token__wrapper', [ + h('div.add-token__title-container', [ + h('div.add-token__title', 'Add Token'), + h('div.add-token__description', 'Keep track of the tokens you’ve bought with your MetaMask account. If you bought tokens using a different account, those tokens will not appear here.'), + h('div.add-token__description', 'Search for tokens or select from our list of popular tokens.'), + ]), + h('div.add-token__content-container', [ + h('div.add-token__input-container', [ + h('input.add-token__input', { + type: 'text', + placeholder: 'Search', + onChange: e => this.setState({ searchQuery: e.target.value }), + }), + ]), + h( + 'div.add-token__token-icons-container', + this.renderTokenList(), + ), + ]), + h('div.add-token__footers', [ + h('div.add-token__add-custom', { + onClick: () => this.setState({ isCollapsed: !isCollapsed }), + }, 'Add custom token'), + this.renderCustomForm(), + ]), + ]), + h('div.add-token__buttons', [ + h('button.btn-secondary', 'Next'), + h('button.btn-tertiary', 'Cancel'), + ]), + ]) + ) +} + +// AddTokenScreen.prototype.render = function () { +// const state = this.state +// const props = this.props +// const { warning, symbol, decimals } = state + +// return ( +// h('.flex-column.flex-grow', [ + +// // subtitle and nav +// h('.section-title.flex-row.flex-center', [ +// h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { +// onClick: (event) => { +// props.dispatch(actions.goHome()) +// }, +// }), +// h('h2.page-subtitle', 'Add Token'), +// ]), + +// h('.error', { +// style: { +// display: warning ? 'block' : 'none', +// padding: '0 20px', +// textAlign: 'center', +// }, +// }, warning), + +// // conf view +// h('.flex-column.flex-justify-center.flex-grow.select-none', [ +// h('.flex-space-around', { +// style: { +// padding: '20px', +// }, +// }, [ + +// h('div', [ +// h(Tooltip, { +// position: 'top', +// title: 'The contract of the actual token contract. Click for more info.', +// }, [ +// h('a', { +// style: { fontWeight: 'bold', paddingRight: '10px'}, +// href: 'https://consensyssupport.happyfox.com/staff/kb/article/24-what-is-a-token-contract-address', +// target: '_blank', +// }, [ +// h('span', 'Token Contract Address '), +// h('i.fa.fa-question-circle'), +// ]), +// ]), +// ]), + +// h('section.flex-row.flex-center', [ +// h('input#token-address', { +// name: 'address', +// placeholder: 'Token Contract Address', +// onChange: this.tokenAddressDidChange.bind(this), +// style: { +// width: 'inherit', +// flex: '1 0 auto', +// height: '30px', +// margin: '8px', +// }, +// }), +// ]), + +// h('div', [ +// h('span', { +// style: { fontWeight: 'bold', paddingRight: '10px'}, +// }, 'Token Symbol'), +// ]), + +// h('div', { style: {display: 'flex'} }, [ +// h('input#token_symbol', { +// placeholder: `Like "ETH"`, +// value: symbol, +// style: { +// width: 'inherit', +// flex: '1 0 auto', +// height: '30px', +// margin: '8px', +// }, +// onChange: (event) => { +// var element = event.target +// var symbol = element.value +// this.setState({ symbol }) +// }, +// }), +// ]), + +// h('div', [ +// h('span', { +// style: { fontWeight: 'bold', paddingRight: '10px'}, +// }, 'Decimals of Precision'), +// ]), + +// h('div', { style: {display: 'flex'} }, [ +// h('input#token_decimals', { +// value: decimals, +// type: 'number', +// min: 0, +// max: 36, +// style: { +// width: 'inherit', +// flex: '1 0 auto', +// height: '30px', +// margin: '8px', +// }, +// onChange: (event) => { +// var element = event.target +// var decimals = element.value.trim() +// this.setState({ decimals }) +// }, +// }), +// ]), + +// h('button', { +// style: { +// alignSelf: 'center', +// }, +// onClick: (event) => { +// const valid = this.validateInputs() +// if (!valid) return + +// const { address, symbol, decimals } = this.state +// this.props.dispatch(actions.addToken(address.trim(), symbol.trim(), decimals)) +// }, +// }, 'Add'), +// ]), +// ]), +// ]) +// ) +// } + AddTokenScreen.prototype.componentWillMount = function () { if (typeof global.ethereumProvider === 'undefined') return diff --git a/ui/app/css/itcss/components/add-token.scss b/ui/app/css/itcss/components/add-token.scss new file mode 100644 index 000000000..db1d0dc18 --- /dev/null +++ b/ui/app/css/itcss/components/add-token.scss @@ -0,0 +1,173 @@ +.add-token { + width: 498px; + display: flex; + flex-flow: column nowrap; + align-items: center; + position: relative; + top: -36px; + z-index: 12; + font-family: 'DIN Next Light'; + + @media screen and (max-width: $break-small) { + top: 0; + width: 100%; + + &__wrapper { + box-shadow: none !important; + } + + &__footers { + border-bottom: 1px solid $gallery; + } + } + + &__wrapper { + background-color: $white; + box-shadow: 0 2px 4px 0 rgba($black, .08); + display: flex; + flex-flow: column nowrap; + align-items: center; + flex: 0 0 auto; + } + + &__title-container { + display: flex; + flex-flow: column nowrap; + align-items: center; + padding: 30px 60px 12px; + border-bottom: 1px solid $gallery; + flex: 0 0 auto; + } + + &__title { + color: $scorpion; + font-size: 20px; + line-height: 26px; + text-align: center; + font-weight: 600; + margin-bottom: 12px; + } + + &__description { + text-align: center; + } + + &__description + &__description { + margin-top: 24px; + } + + &__content-container { + width: 100%; + border-bottom: 1px solid $gallery; + } + + &__input-container { + padding: 11px 0; + width: 263px; + margin: 0 auto; + } + + &__input { + width: 100%; + border: 2px solid $gallery; + border-radius: 4px; + padding: 5px 15px; + font-size: 14px; + line-height: 19px; + + &::placeholder { + color: $silver; + } + } + + &__footers { + width: 100%; + } + + &__add-custom { + color: $scorpion; + font-size: 18px; + line-height: 24px; + text-align: center; + padding: 11px 0 19px; + font-weight: 600; + cursor: pointer; + } + + &__add-custom-form { + display: flex; + flex-flow: column nowrap; + margin: 8px 0 51px; + } + + &__add-custom-field { + width: 290px; + margin: 0 auto; + } + + &__add-custom-label { + font-size: 16px; + line-height: 21px; + margin-bottom: 8px; + } + + &__add-custom-input { + width: 100%; + border: 1px solid $silver; + padding: 5px 15px; + font-size: 14px; + line-height: 19px; + + &::placeholder { + color: $silver; + } + } + + &__add-custom-field + &__add-custom-field { + margin-top: 21px; + } + + &__buttons { + display: flex; + flex-flow: column nowrap; + margin: 30px 0 51px; + flex: 0 0 auto; + } + + &__token-icons-container { + display: flex; + flex-flow: row wrap; + } + + &__token-wrapper { + display: flex; + flex-flow: row nowrap; + flex: 0 0 50%; + align-items: center; + padding: 24px 0 24px 24px; + } + + &__token-name { + font-size: 14px; + line-height: 19px; + } + + &__token-symbol { + font-size: 22px; + line-height: 29px; + font-weight: 600; + } + + &__token-icon { + width: 60px; + height: 60px; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + border-radius: 50%; + background-color: $white; + box-shadow: 0 2px 4px 0 rgba($black, .24); + margin-right: 12px; + flex: 0 0 auto; + } +} diff --git a/ui/app/css/itcss/components/index.scss b/ui/app/css/itcss/components/index.scss index 63ac8bd47..9b3690099 100644 --- a/ui/app/css/itcss/components/index.scss +++ b/ui/app/css/itcss/components/index.scss @@ -27,3 +27,5 @@ @import './sections.scss'; @import './token-list.scss'; + +@import './add-token.scss'; diff --git a/yarn.lock b/yarn.lock index aed3a9da8..078ab75cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1950,6 +1950,12 @@ cli-width@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" +cli@0.4.3: + version "0.4.3" + resolved "https://registry.yarnpkg.com/cli/-/cli-0.4.3.tgz#e6819c8d5faa957f64f98f66a8506268c1d1f17d" + dependencies: + glob ">= 3.1.4" + client-sw-ready-event@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/client-sw-ready-event/-/client-sw-ready-event-3.3.0.tgz#988d1045562b0c228e33d9247a6dd3ed7b276fe3" @@ -2063,7 +2069,7 @@ colors@1.0.3, colors@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" -colors@^1.1.0, colors@^1.1.2: +colors@>=0.6.x, colors@^1.1.0, colors@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" @@ -3370,7 +3376,7 @@ eth-block-tracker@^2.0.1, eth-block-tracker@^2.1.2: pify "^2.3.0" tape "^4.6.3" -eth-contract-metadata@^1.1.4: +eth-contract-metadata@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/eth-contract-metadata/-/eth-contract-metadata-1.1.5.tgz#301f51b0460b8dd044997dc05870751fb7f4cfcb" @@ -4211,6 +4217,20 @@ functional-red-black-tree@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" +fuse.js@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.1.0.tgz#9062146c471552189b0f678b4f5a155731ae3b3c" + +fuse@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/fuse/-/fuse-0.4.0.tgz#2c38eaf888abb0a9ba7960cfe3339d1f3f53f6e6" + dependencies: + colors ">=0.6.x" + jshint "0.9.x" + optimist ">=0.3.5" + uglify-js ">=2.2.x" + underscore ">=1.4.x" + gather-stream@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/gather-stream/-/gather-stream-1.0.0.tgz#b33994af457a8115700d410f317733cbe7a0904b" @@ -4346,17 +4366,7 @@ glob@7.1.1: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^5.0.15, glob@^5.0.3, glob@~5.0.0: - version "5.0.15" - resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1" - dependencies: - inflight "^1.0.4" - inherits "2" - minimatch "2 || 3" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@^7.0.0, glob@^7.0.3, glob@^7.0.4, glob@^7.0.5, glob@^7.0.6, glob@^7.1.0, glob@^7.1.1, glob@^7.1.2, glob@~7.1.1, glob@~7.1.2: +"glob@>= 3.1.4", glob@^7.0.0, glob@^7.0.3, glob@^7.0.4, glob@^7.0.5, glob@^7.0.6, glob@^7.1.0, glob@^7.1.1, glob@^7.1.2, glob@~7.1.1, glob@~7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" dependencies: @@ -4367,6 +4377,16 @@ glob@^7.0.0, glob@^7.0.3, glob@^7.0.4, glob@^7.0.5, glob@^7.0.6, glob@^7.1.0, gl once "^1.3.0" path-is-absolute "^1.0.0" +glob@^5.0.15, glob@^5.0.3, glob@~5.0.0: + version "5.0.15" + resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1" + dependencies: + inflight "^1.0.4" + inherits "2" + minimatch "2 || 3" + once "^1.3.0" + path-is-absolute "^1.0.0" + global-modules@^0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-0.2.3.tgz#ea5a3bed42c6d6ce995a4f8a1269b5dae223828d" @@ -5521,6 +5541,13 @@ jshint-stylish@~2.2.1: string-length "^1.0.0" text-table "^0.2.0" +jshint@0.9.x: + version "0.9.1" + resolved "https://registry.yarnpkg.com/jshint/-/jshint-0.9.1.tgz#ff32ec7f09f84001f7498eeafd63c9e4fbb2dc0e" + dependencies: + cli "0.4.3" + minimatch "0.0.x" + jsmin@1.x: version "1.0.1" resolved "https://registry.yarnpkg.com/jsmin/-/jsmin-1.0.1.tgz#e7bd0dcd6496c3bf4863235bf461a3d98aa3b98c" @@ -6236,6 +6263,10 @@ lru-cache@^4.0.1: pseudomap "^1.0.2" yallist "^2.1.2" +lru-cache@~1.0.2: + version "1.0.6" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-1.0.6.tgz#aa50f97047422ac72543bda177a9c9d018d98452" + lru-queue@0.1: version "0.1.0" resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" @@ -6487,6 +6518,12 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" +minimatch@0.0.x: + version "0.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-0.0.5.tgz#96bb490bbd3ba6836bbfac111adf75301b1584de" + dependencies: + lru-cache "~1.0.2" + "minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, minimatch@~3.0.2: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" @@ -7074,7 +7111,7 @@ opener@^1.3.0: version "1.4.3" resolved "https://registry.yarnpkg.com/opener/-/opener-1.4.3.tgz#5c6da2c5d7e5831e8ffa3964950f8d6674ac90b8" -optimist@^0.6.1: +optimist@>=0.3.5, optimist@^0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" dependencies: @@ -9697,6 +9734,13 @@ uglify-js@1.x: version "1.3.5" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-1.3.5.tgz#4b5bfff9186effbaa888e4c9e94bd9fc4c94929d" +uglify-js@>=2.2.x: + version "3.1.1" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.1.1.tgz#e7144307281a1bc38a9a20715090b546c9f44791" + dependencies: + commander "~2.11.0" + source-map "~0.5.1" + uglify-js@^2.6, uglify-js@^2.8.27: version "2.8.29" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd" @@ -9736,7 +9780,7 @@ unc-path-regex@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" -underscore@>=1.8.3, underscore@^1.6.0: +underscore@>=1.4.x, underscore@>=1.8.3, underscore@^1.6.0: version "1.8.3" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022"