From ec10b93878c252e098d2b92b702583c45d0657ee Mon Sep 17 00:00:00 2001 From: nutrina Date: Mon, 23 May 2022 08:45:48 +0300 Subject: [PATCH] New bounty creation flow (#10409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * New bounty creation flow * Minor fixes: adressing comments from review * Bug fixes: - redirect to new url format from bounty invitation - fix failure loading details page due to invalid JSON in acceptance criteria and ressources - ®emved hardcoded URLs for network icons * Adding forgotten vue template to new_bounty.html in hackathons * Displaying the funding orgs avatar for custom bounties Co-authored-by: Gerald Iakobinyi-Pich --- app/app/fixtures/economy.json | 156 +- app/app/urls.py | 5 + app/assets/v2/js/data-chains.js | 19 +- app/assets/v2/js/grants/form_wrapper.js | 2 +- app/assets/v2/js/pages/bounty_details.js | 13 - app/assets/v2/js/pages/bounty_details2.js | 52 +- app/assets/v2/js/pages/bounty_progress_bar.js | 14 + app/assets/v2/js/pages/change_bounty.js | 1490 +++++++++++++---- app/assets/v2/js/pages/new_bounty.js | 754 +++++++-- app/assets/v2/js/vue-components.js | 27 +- app/assets/v2/scss/bounty.scss | 35 + app/assets/v2/scss/grants/form_wrapper.scss | 49 + app/assets/v2/scss/submit_bounty.scss | 10 + app/dashboard/admin.py | 2 +- app/dashboard/helpers.py | 24 + .../determine_bounties_never_expires_field.py | 54 + .../migrations/0203_auto_20220518_0612.py | 89 + app/dashboard/models.py | 141 +- app/dashboard/router.py | 25 +- .../templates/bounty/bounty_progress_bar.html | 24 + app/dashboard/templates/bounty/change.html | 167 +- app/dashboard/templates/bounty/details2.html | 182 +- app/dashboard/templates/bounty/fulfill.html | 10 +- .../templates/bounty/new_bounty.html | 891 ++-------- .../templates/bounty/new_bounty_step_1.html | 80 + .../templates/bounty/new_bounty_step_2.html | 237 +++ .../templates/bounty/new_bounty_step_3.html | 129 ++ .../templates/bounty/new_bounty_step_4.html | 188 +++ .../templates/bounty/new_bounty_step_5.html | 179 ++ .../dashboard/hackathon/new_bounty.html | 452 +---- .../templates/dashboard/sidebar_search.html | 13 +- .../templates/shared/bounty_categories.html | 39 - .../templates/shared/bounty_details.html | 59 - .../templates/shared/bounty_keywords.html | 27 - .../templates/shared/issue_details.html | 112 -- app/dashboard/templates/shared/reserved.html | 62 - app/dashboard/tests/test_dashboard_models.py | 7 +- app/dashboard/views.py | 154 +- app/economy/fixtures/tokens.json | 171 ++ .../grants/components/form_wrapper.html | 1 + app/retail/templates/shared/result.html | 48 +- app/retail/views.py | 1 + cypress.json | 2 +- .../bounties/test_bounty_creation.js | 678 +++++++- ...est_bounty_creation_add_remove_contacts.js | 111 ++ .../test_bounty_creation_additional_owners.js | 147 ++ .../test_bounty_creation_custom_issue.js | 131 ++ .../bounties/test_bounty_creation_payment.js | 134 ++ docker-compose.yml | 2 +- infra/review-pr/index.ts | 12 +- 50 files changed, 5186 insertions(+), 2225 deletions(-) create mode 100644 app/assets/v2/js/pages/bounty_progress_bar.js create mode 100644 app/dashboard/management/commands/determine_bounties_never_expires_field.py create mode 100644 app/dashboard/migrations/0203_auto_20220518_0612.py create mode 100644 app/dashboard/templates/bounty/bounty_progress_bar.html create mode 100644 app/dashboard/templates/bounty/new_bounty_step_1.html create mode 100644 app/dashboard/templates/bounty/new_bounty_step_2.html create mode 100644 app/dashboard/templates/bounty/new_bounty_step_3.html create mode 100644 app/dashboard/templates/bounty/new_bounty_step_4.html create mode 100644 app/dashboard/templates/bounty/new_bounty_step_5.html delete mode 100644 app/dashboard/templates/shared/bounty_categories.html delete mode 100644 app/dashboard/templates/shared/bounty_details.html delete mode 100644 app/dashboard/templates/shared/bounty_keywords.html delete mode 100644 app/dashboard/templates/shared/issue_details.html delete mode 100644 app/dashboard/templates/shared/reserved.html create mode 100644 cypress/integration/bounties/test_bounty_creation_add_remove_contacts.js create mode 100644 cypress/integration/bounties/test_bounty_creation_additional_owners.js create mode 100644 cypress/integration/bounties/test_bounty_creation_custom_issue.js create mode 100644 cypress/integration/bounties/test_bounty_creation_payment.js diff --git a/app/app/fixtures/economy.json b/app/app/fixtures/economy.json index 30539954431..ade8435cbf7 100644 --- a/app/app/fixtures/economy.json +++ b/app/app/fixtures/economy.json @@ -18939,7 +18939,7 @@ "symbol": "ETH", "network": "mainnet", "decimals": 18, - "priority": 999, + "priority": 1, "metadata": {}, "approved": true } @@ -21449,21 +21449,6 @@ "approved": true } }, - { - "model": "economy.token", - "pk": 171, - "fields": { - "created_on": "2018-12-26T17:00:13.244Z", - "modified_on": "2018-12-26T17:00:13.244Z", - "address": "0x0000000000000000000000000000000000000000", - "symbol": "ETH", - "network": "ropsten", - "decimals": 18, - "priority": 999, - "metadata": {}, - "approved": true - } - }, { "model": "economy.token", "pk": 172, @@ -21509,21 +21494,6 @@ "approved": true } }, - { - "model": "economy.token", - "pk": 175, - "fields": { - "created_on": "2018-12-26T17:00:13.246Z", - "modified_on": "2018-12-26T17:00:13.246Z", - "address": "0x0000000000000000000000000000000000000000", - "symbol": "ETH", - "network": "rinkeby", - "decimals": 18, - "priority": 999, - "metadata": {}, - "approved": true - } - }, { "model": "economy.token", "pk": 176, @@ -21554,21 +21524,6 @@ "approved": true } }, - { - "model": "economy.token", - "pk": 178, - "fields": { - "created_on": "2018-12-26T17:00:13.249Z", - "modified_on": "2018-12-26T17:00:13.249Z", - "address": "0x0000000000000000000000000000000000000000", - "symbol": "ETH", - "network": "custom", - "decimals": 18, - "priority": 999, - "metadata": {}, - "approved": true - } - }, { "model": "economy.token", "pk": 179, @@ -21584,21 +21539,6 @@ "approved": true } }, - { - "model": "economy.token", - "pk": 180, - "fields": { - "created_on": "2018-12-26T17:00:13.251Z", - "modified_on": "2018-12-26T17:00:13.251Z", - "address": "0x0000000000000000000000000000000000000000", - "symbol": "ETH", - "network": "unknown", - "decimals": 18, - "priority": 999, - "metadata": {}, - "approved": true - } - }, { "model": "economy.token", "pk": 181, @@ -21629,21 +21569,6 @@ "approved": true } }, - { - "model": "economy.token", - "pk": 183, - "fields": { - "created_on": "2018-12-26T18:45:52.084Z", - "modified_on": "2018-12-26T18:45:52.084Z", - "address": "0x0000000000000000000000000000000000000000", - "symbol": "ETH", - "network": "mainnet", - "decimals": 18, - "priority": 999, - "metadata": {}, - "approved": true - } - }, { "model": "economy.token", "pk": 184, @@ -24149,21 +24074,6 @@ "approved": true } }, - { - "model": "economy.token", - "pk": 351, - "fields": { - "created_on": "2018-12-26T18:45:52.213Z", - "modified_on": "2018-12-26T18:45:52.213Z", - "address": "0x0000000000000000000000000000000000000000", - "symbol": "ETH", - "network": "ropsten", - "decimals": 18, - "priority": 999, - "metadata": {}, - "approved": true - } - }, { "model": "economy.token", "pk": 352, @@ -24209,21 +24119,6 @@ "approved": true } }, - { - "model": "economy.token", - "pk": 355, - "fields": { - "created_on": "2018-12-26T18:45:52.216Z", - "modified_on": "2018-12-26T18:45:52.216Z", - "address": "0x0000000000000000000000000000000000000000", - "symbol": "ETH", - "network": "rinkeby", - "decimals": 18, - "priority": 999, - "metadata": {}, - "approved": true - } - }, { "model": "economy.token", "pk": 356, @@ -24254,21 +24149,6 @@ "approved": true } }, - { - "model": "economy.token", - "pk": 358, - "fields": { - "created_on": "2018-12-26T18:45:52.217Z", - "modified_on": "2018-12-26T18:45:52.217Z", - "address": "0x0000000000000000000000000000000000000000", - "symbol": "ETH", - "network": "custom", - "decimals": 18, - "priority": 999, - "metadata": {}, - "approved": true - } - }, { "model": "economy.token", "pk": 359, @@ -24284,21 +24164,6 @@ "approved": true } }, - { - "model": "economy.token", - "pk": 360, - "fields": { - "created_on": "2018-12-26T18:45:52.219Z", - "modified_on": "2018-12-26T18:45:52.219Z", - "address": "0x0000000000000000000000000000000000000000", - "symbol": "ETH", - "network": "unknown", - "decimals": 18, - "priority": 999, - "metadata": {}, - "approved": true - } - }, { "model": "economy.token", "pk": 361, @@ -24525,5 +24390,24 @@ "conversion_rate_id": "harmony", "conversion_rate_source": "coingecko" } + }, + { + "model": "economy.token", + "pk": 374, + "fields": { + "created_on": "2022-04-22T12:35:27Z", + "modified_on": "2022-04-22T12:36:06.267Z", + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "network": "testnetrpc", + "decimals": 18, + "priority": 1, + "chain_id": 1, + "network_id": 1, + "metadata": {}, + "approved": true, + "conversion_rate_id": null, + "conversion_rate_source": null + } } ] diff --git a/app/app/urls.py b/app/app/urls.py index 5766dc044f5..a6d667cd801 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -443,6 +443,9 @@ ), # View Bounty + # TODO: The 2 URLs below issue_details_new2 and issue_details_new3 will not be used any more (we should not create such + # links any more), and can be removed in the future. We are keeping these for now to avoid breaking the + # existing links that users might have saved already. url( r'^issue/(?P.*)/(?P.*)/(?P.*)/(?P.*)', dashboard.views.bounty_details, @@ -454,6 +457,7 @@ name='issue_details_new2' ), re_path(r'^funding/details/?', dashboard.views.bounty_details, name='funding_details'), + re_path(r'^issue/(?P\d+)', dashboard.views.bounty_details, name='issue_details_new4'), re_path(r'^issue/(?P.*)', dashboard.views.bounty_invite_url, name='unique_bounty_invite'), # Tips @@ -513,6 +517,7 @@ url(r'^sync/web3/?', dashboard.views.sync_web3, name='sync_web3'), url(r'^sync/get_amount/?', dashboard.helpers.amount, name='helpers_amount'), re_path(r'^sync/get_issue_details/?', dashboard.helpers.issue_details, name='helpers_issue_details'), + re_path(r'^sync/validate_org_url/?', dashboard.helpers.validate_org_url, name='helpers_validate_org_url'), # modals re_path(r'^modal/get_quickstart_video/?', dashboard.views.get_quickstart_video, name='get_quickstart_video'), diff --git a/app/assets/v2/js/data-chains.js b/app/assets/v2/js/data-chains.js index 0dc4a7b503b..dd93eca9251 100644 --- a/app/assets/v2/js/data-chains.js +++ b/app/assets/v2/js/data-chains.js @@ -1449,7 +1449,24 @@ var dataChains = "shortName": "hmy-b-s3", "chainId": 1666700003, "networkId": 1666700003 - } + }, + { // Creating a testnet entry, with the ID 1111111111, such that it is possible to use testnet when running integration tests for payments + "name": "Ethereum Testnet RPC", + "chainId": 1111111111, + "shortName": "rpc", + "chain": "ETH", + "network": "testnetrpc", + "networkId": 1111111111, + "nativeCurrency": { + "name": "Testnet Ether", + "symbol": "ETH", + "decimals": 18 + }, + "rpc": [ + ], + "faucets": [ + ], + }, ]; function searchDataChains(name, obj) { diff --git a/app/assets/v2/js/grants/form_wrapper.js b/app/assets/v2/js/grants/form_wrapper.js index 13245a0816d..809946ac486 100644 --- a/app/assets/v2/js/grants/form_wrapper.js +++ b/app/assets/v2/js/grants/form_wrapper.js @@ -10,7 +10,7 @@ Vue.component('form-wrapper', { 'total-steps': { type: Number, required: true }, - disableConfirm: { + 'disable-confirm': { type: Boolean, 'default': false }, diff --git a/app/assets/v2/js/pages/bounty_details.js b/app/assets/v2/js/pages/bounty_details.js index 272ed19c9d3..3424d3ddf87 100644 --- a/app/assets/v2/js/pages/bounty_details.js +++ b/app/assets/v2/js/pages/bounty_details.js @@ -333,19 +333,6 @@ var callbacks = { ); } - - $('#value_in_usdt').html(Math.round(totalUSDValue)); - - $('#value_in_usdt_wrapper').attr('title', - '
' + - '

How did we calculate this?

' + - '
' + - leftHtml + - '
' + - rightHtml + - '
' - ); - return [ key, val ]; }, 'token_value_time_peg': function(key, val, result) { diff --git a/app/assets/v2/js/pages/bounty_details2.js b/app/assets/v2/js/pages/bounty_details2.js index c7f605c97ff..48073a04306 100644 --- a/app/assets/v2/js/pages/bounty_details2.js +++ b/app/assets/v2/js/pages/bounty_details2.js @@ -10,29 +10,32 @@ const loadingState = { document.result = bounty; +Vue.use(VueQuillEditor); +Vue.component('v-select', VueSelect.VueSelect); + Vue.mixin({ methods: { fetchBounty: function(newData) { let vm = this; - let apiUrlBounty = `/actions/api/v0.1/bounty?github_url=${document.issueURL}`; + let apiUrlBounty = document.bountyID ? `/actions/api/v0.1/bounty/${document.bountyID}` : `/actions/api/v0.1/bounty?github_url=${document.issueURL}`; const getBounty = fetchData(apiUrlBounty, 'GET'); $.when(getBounty).then(function(response) { - if (!response.length) { + if (!document.bountyID && !response.length) { vm.loadingState = 'empty'; return vm.syncBounty(); } - vm.bounty = response[0]; + vm.bounty = document.bountyID ? response : response[0]; vm.loadingState = 'resolved'; - vm.isOwner = vm.checkOwner(response[0].bounty_owner_github_username); - vm.isOwnerAddress = vm.checkOwnerAddress(response[0].bounty_owner_address); - document.result = response[0]; + vm.isOwner = vm.checkOwner(); + vm.isOwnerAddress = vm.checkOwnerAddress(vm.bounty.bounty_owner_address); + document.result = vm.bounty; if (newData) { delete sessionStorage['fulfillers']; delete sessionStorage['bountyId']; localStorage[document.issueURL] = ''; - document.title = `${response[0].title} | Gitcoin`; - window.history.replaceState({}, `${response[0].title} | Gitcoin`, response[0].url); + document.title = `${vm.bounty.title} | Gitcoin`; + window.history.replaceState({}, `${vm.bounty.title} | Gitcoin`, vm.bounty.url); } if (vm.bounty.event && localStorage['pendingProject'] && (vm.bounty.standard_bounties_id == localStorage['pendingProject'])) { projectModal(vm.bounty.pk); @@ -343,14 +346,27 @@ Vue.mixin({ waitBlock(bountyMetadata.txid); }, - checkOwner: function(handle) { + checkOwner: function() { let vm = this; + let ret = false; + let owner_handle = vm.bounty.bounty_owner_github_username; if (vm.contxt.github_handle) { - return caseInsensitiveCompare(document.contxt['github_handle'], handle); + ret = caseInsensitiveCompare(document.contxt['github_handle'], owner_handle); } - return false; + // Check also for additional bounty owners + if (!ret) { + for (let i = 0; i < vm.bounty.owners.length; i++) { + let additionalOwner = vm.bounty.owners[i]; + + ret = caseInsensitiveCompare(document.contxt['github_handle'], additionalOwner.handle); + if (ret) { + break; + } + } + } + return ret; }, checkOwnerAddress: function(bountyOwnerAddress) { let vm = this; @@ -430,6 +446,14 @@ Vue.mixin({ }); } }, + parseJSONNoFail(jsonString) { + try { + return JSON.parse(jsonString); + } catch (error) { + // Nothing to do + } + return ''; + }, getTenant: function(token_name, web3_type) { let tenant; let vm = this; @@ -934,6 +958,12 @@ Vue.mixin({ }, expiresAfterAYear: function() { return moment().diff(document.result['expires_date'], 'years') < -1; + }, + isPayoutDateExpired: function() { + return moment(document.result['payout_date']).isBefore(); + }, + payoutDateExpiresAfterAYear: function() { + return moment().diff(document.result['payout_date'], 'years') < -1; } } }); diff --git a/app/assets/v2/js/pages/bounty_progress_bar.js b/app/assets/v2/js/pages/bounty_progress_bar.js new file mode 100644 index 00000000000..c0613df66e3 --- /dev/null +++ b/app/assets/v2/js/pages/bounty_progress_bar.js @@ -0,0 +1,14 @@ +Vue.component('bounty-progress-bar', { + props: { + steps: { type: Array, 'default': () => [] } + }, + delimiters: [ '[[', ']]' ], + computed: { + lineWidth() { + return (100 / this.steps.length) * (this.steps.length - 1); + }, + margin() { + return (100 / this.steps.length) / 2; + } + } +}); diff --git a/app/assets/v2/js/pages/change_bounty.js b/app/assets/v2/js/pages/change_bounty.js index 381c6135ea8..b7a85a12d8d 100644 --- a/app/assets/v2/js/pages/change_bounty.js +++ b/app/assets/v2/js/pages/change_bounty.js @@ -1,380 +1,1292 @@ -// Overwrite from shared.js -// eslint-disable-next-line no-empty-function -function trigger_form_hooks() { -} +let appFormBounty; + +window.addEventListener('dataWalletReady', function(e) { + appFormBounty.network = networkName; + appFormBounty.form.funderAddress = selectedAccount; +}, false); -let usersBySkills; -let processedData; +const helpText = { + '#new-bounty-acceptace-criteria': 'Check out great examples of acceptance criteria from some of our past successful bounties!' +}; -const populateFromAPI = bounty => { - if (bounty && bounty.is_featured) { - $('#featuredBounty').prop('checked', true); - $('#featuredBounty').prop('disabled', true); +const bountyTypes = [ + 'Bug', + 'Project', + 'Feature', + 'Security', + 'Improvement', + 'Design', + 'Docs', + 'Code review', + 'Other' +]; + +Vue.use(VueQuillEditor); +Vue.component('v-select', VueSelect.VueSelect); +Vue.component('quill-editor-ext', { + props: [ 'initial', 'options' ], + template: '#quill-editor-ext', + data() { + return { + }; + }, + methods: { + onUpdate: function(event) { + this.$emit('change', { + text: event.text, + delta: event.quill.getContents() + }); + } + }, + mounted() { + this.$refs.quillEditor.quill.setContents(this.initial); } +}); - $.each(bounty, function(key, value) { - let ctrl = $('[name=' + key + ']', $('#submitBounty')); +Vue.mixin({ + data() { + return { + step: 1, + bountyTypes: bountyTypes, + tagOptions: [ + 'JavaScript', + 'TypeScript', + 'HTML', + 'Solidity', + 'CSS', + 'Python', + 'React', + 'Ethereum', + 'Blockchain', + 'DeFi', + 'Shell', + 'web3', + 'Design', + 'NFT', + 'Rust', + 'Dockerfile', + 'Go', + 'Community', + 'dApp', + 'API', + 'Documentation', + 'DAO', + 'Smart contract', + 'UI/UX', + 'POAP' + ], + contactDetailsType: [ + 'Discord', + 'Telegram', + 'Email' + ], + contactDetailsPlaceholderMap: { + '': 'mydiscord#1234', + 'Discord': 'mydiscord#1234', + 'Telegram': 'mytelegramusername', + 'Email': 'my@email.com' + }, + networkOptions: [ + { + 'id': '1', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/ethereum.512bdfc90974.svg', + 'label': 'ETH' + }, + { + 'id': '0', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/bitcoin.a606afe92dc0.svg', + 'label': 'BTC' + }, + { + 'id': '666', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/paypal.94a717ec583d.svg', + 'label': 'PayPal' + }, + { + 'id': '56', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/binance.f29b8c5b883c.svg', + 'label': 'Binance' + }, + { + 'id': '1000', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/harmony.94e314f87cb6.svg', + 'label': 'Harmony' + }, + { + 'id': '58', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/polkadot.ab164a0162c0.svg', + 'label': 'Polkadot' + }, + { + 'id': '59', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/kusama.79f72c4ef309.svg', + 'label': 'Kusama' + }, + { + 'id': '61', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/ethereum-classic.5da22d66e88a.svg', + 'label': 'ETC' + }, + { + 'id': '102', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/zilliqa.53f121329fe2.svg', + 'label': 'Zilliqa' + }, + { + 'id': '600', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/filecoin.5b66dcda075a.svg', + 'label': 'Filecoin' + }, + { + 'id': '42220', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/celo.92f6ddaad4cd.svg', + 'label': 'Celo' + }, + { + 'id': '30', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/rsk.ad4762fa3b4b.svg', + 'label': 'RSK' + }, + { + 'id': '50', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/xinfin.dfca06ac5f24.svg', + 'label': 'Xinfin' + }, + { + 'id': '1001', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/algorand.25e6b9cd9ae9.svg', + 'label': 'Algorand' + }, + { + 'id': '1935', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/sia.1aeab380df24.svg', + 'label': 'Sia' + }, + { + 'id': '1995', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/nervos.e3e776d77e06.svg', + 'label': 'Nervos' + }, + { + 'id': '50797', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/tezos.66a5e2b53980.svg', + 'label': 'Tezos' + }, + { + 'id': '270895', + 'logo': 'https://s.gitcoin.co/static/v2/images/chains/casper.4718c7855050.svg', + 'label': 'Casper' + }, + { + 'id': '717171', + 'logo': null, + 'label': 'Other' + } + ] + }; + }, + methods: { + getContactDetailsPlaceholder: function(val) { + return this.contactDetailsPlaceholderMap[val] || this.contactDetailsPlaceholderMap['']; + }, + estHoursValidator: function() { + this.form.hours = parseFloat(this.form.hours || 0); + this.form.hours = Math.ceil(this.form.hours); + this.calcValues('token'); + }, + getIssueDetails: function(url) { + let vm = this; - switch (ctrl.prop('type')) { - case 'radio': - $(`.${value}`).button('toggle'); - break; + if (!url) { + vm.$set(vm.errors, 'issueDetails', undefined); + vm.form.issueDetails = null; + return vm.form.issueDetails; + } - case 'checkbox': - ctrl.each(function() { - if (value.length) { - $.each(value, function(key, val) { - $(`.${val}`).button('toggle'); - }); - } else { - $(this).prop('checked', value); - } - }); - break; + let ghIssueUrl; - case 'select-one': - $('#' + key).val(value).trigger('change'); - break; + try { + ghIssueUrl = new URL(url); + } catch (e) { + vm.form.issueDetails = undefined; + vm.$set(vm.errors, 'issueDetails', 'Please paste a github issue url'); + return; + } - default: - if (value > 0) { - ctrl.val(value); - } - } - }); + if (ghIssueUrl.host != 'github.com') { + vm.form.issueDetails = undefined; + vm.$set(vm.errors, 'issueDetails', 'Please paste a github issue url'); + return; + } - if (bounty && bounty.keywords) { + if (ghIssueUrl.pathname.includes('/pull/')) { + vm.$set(vm.errors, 'issueDetails', 'Please paste a github issue url and not a PR'); + return; + } - let keywords = bounty['keywords'].split(','); - $('#keywords').select2({ - placeholder: 'Select tags', - data: keywords, - tags: true, - allowClear: true, - tokenSeparators: [ ',', ' ' ] - }).trigger('change'); + vm.orgSelected = ghIssueUrl.pathname.split('/')[1].toLowerCase(); - $('#keywords').val(keywords).trigger('change'); - $('#keyword-suggestion-container').hide(); - } -}; + if (vm.checkBlocked(vm.orgSelected)) { + vm.$set(vm.errors, 'issueDetails', 'This repo is not bountyable at the request of the maintainer.'); + vm.form.issueDetails = undefined; + return; + } + vm.$delete(vm.errors, 'issueDetails'); -const formatUser = user => { - if (!user.text || user.children) { - return user.text; - } + let apiUrldetails = `/sync/get_issue_details?url=${encodeURIComponent(url.trim())}&duplicates=true&network=${vm.network}`; - const markup = ` -
-
- -
-
${user.text}
-
`; + if (vm.hackathon_slug) { + apiUrldetails += `&hackathon_slug=${encodeURIComponent(vm.hackathon_slug)}`; + } - return markup; -}; + vm.form.issueDetails = undefined; + const getIssue = fetchData(apiUrldetails, 'GET'); -const formatUserSelection = user => { - let selected; + $.when(getIssue).then((response) => { + if (!Object.keys(response).length) { + return vm.$set(vm.errors, 'issueDetails', 'Nothing found. Please check the issue URL.'); + } - if (user.id) { - selected = ` - - ${user.text}`; - } else { - selected = user.text; - } + vm.form.issueDetails = response; - return selected; -}; + let md = window.markdownit(); -const getSuggestions = () => { + vm.form.richDescription = md.render(vm.form.issueDetails.description); + vm.form.title = vm.form.issueDetails.title; - let keywords = $('#keywords').val(); + vm.$set(vm.errors, 'issueDetails', undefined); + }).catch((err) => { + console.log(err); + vm.form.issueDetails = undefined; + vm.$set(vm.errors, 'issueDetails', err.responseJSON.message); + }); - const settings = { - url: `/api/v0.1/get_suggested_contributors?keywords=${keywords}`, - method: 'GET', - processData: false, - dataType: 'json', - contentType: false - }; + }, + validateOrgUrl: function(url) { + let vm = this; - $.ajax(settings).done(function(response) { - let groups = { - 'contributors': 'Recently worked with you', - 'recommended_developers': 'Recommended based on skills', - 'verified_developers': 'Verified contributors' - }; + if (!url) { + vm.$set(vm.errors, 'orgDetails', undefined); + return; + } - let options = Object.entries(response).map(([ text, children ]) => ( - { text: groups[text], children } - )); + let ghIssueUrl; - usersBySkills = [].map.call(response['recommended_developers'], function(obj) { - return obj; - }); + try { + ghIssueUrl = new URL(url); + } catch (e) { + vm.$set(vm.errors, 'orgDetails', 'Please paste a github org url'); + return; + } - if (keywords.length && usersBySkills.length) { - $('#invite-all-container').show(); - $('.select2-add_byskill span').text(keywords.join(', ')); - } else { - $('#invite-all-container').hide(); - } + if (ghIssueUrl.host != 'github.com') { + vm.$set(vm.errors, 'orgDetails', 'Please paste a github org url'); + return; + } - let generalIndex = 0; + let apiUrldetails = `/sync/validate_org_url?url=${encodeURIComponent(url.trim())}`; - processedData = $.map(options, function(obj) { - if (obj.children.length < 1) { - return; + if (vm.hackathon_slug) { + apiUrldetails += `&hackathon_slug=${encodeURIComponent(vm.hackathon_slug)}`; } - obj.children.forEach(children => { - children.text = children.profile__handle || children.user__profile__handle; - children.id = generalIndex; - generalIndex++; + vm.form.orgDetails = undefined; + const getIssue = fetchData(apiUrldetails, 'GET'); + + $.when(getIssue).then((response) => { + vm.$set(vm.errors, 'orgDetails', undefined); + }).catch((err) => { + console.log(err); + vm.form.issueDetails = undefined; + vm.$set(vm.errors, 'orgDetails', err.responseJSON.message); + }); + }, + getTokens: function() { + let vm = this; + const apiUrlTokens = '/api/v1/tokens/'; + const getTokensData = fetchData(apiUrlTokens, 'GET'); + + return $.when(getTokensData).then((response) => { + vm.tokens = response; + // vm.form.token = vm.filterByChainId[0]; + vm.getAmount(vm.form.token.symbol); + + }).catch((err) => { + console.log(err); }); - return obj; - }); - - $('#invite-contributors').select2().empty(); - $('#invite-contributors.js-select2').select2({ - data: processedData, - placeholder: 'Select contributors', - escapeMarkup: function(markup) { - return markup; - }, - templateResult: formatUser, - templateSelection: formatUserSelection - }); - }).fail(function(error) { - console.log('Could not fetch contributors', error); - }); -}; + }, + getBinanceSelectedAccount: async function() { + let vm = this; -$(document).ready(function() { + try { + vm.form.funderAddress = await binance_utils.getSelectedAccount(); + } catch (error) { + vm.funderAddressFallback = true; + } + }, + onChainInput: function() { + this.form.token = null; + this.form.amount = 0; + this.form.amountusd = 0; + }, + getAmount: function(token) { + let vm = this; - const bounty = document.result; - const form = $('#submitBounty'); + if (!token) { + return; + } + const apiUrlAmount = `/sync/get_amount?amount=1&denomination=${token}`; + const getAmountData = fetchData(apiUrlAmount, 'GET'); + + $.when(getAmountData).then(tokens => { + vm.coinValue = tokens[0].usdt; + // vm.calcValues('usd'); + }).catch((err) => { + console.log(err); + }); + }, + calcValues: function(direction) { + let vm = this; - populateFromAPI(bounty); + if (direction == 'usd') { + let usdValue = vm.form.amount * vm.coinValue; - $('.js-select2').each(function() { - $(this).select2({ - minimumResultsForSearch: Infinity - }); - }); + vm.form.amountusd = Number(usdValue.toFixed(2)); + } else { + vm.form.amount = Number(vm.form.amountusd * 1 / vm.coinValue).toFixed(4); + } - $('[name=project_type]').on('change', function() { - const val = $('input[name=project_type]:checked').val(); + }, + addKeyword: function(item) { + let vm = this; - if (val !== 'traditional') { - $('#reservedFor').attr('disabled', true); - $('#reservedFor').select2().trigger('change'); - } else { - $('#reservedFor').attr('disabled', false); - userSearch('#reservedFor', false); - } - }).triggerHandler('change'); + vm.form.keywords.push(item); + }, + validateFunderAddress: function() { + let vm = this; - $('[name=permission_type]').on('change', function() { - const val = $('input[name=permission_type]:checked').val(); + return validateWalletAddress(vm.chainId, vm.form.funderAddress); + }, + checkFormStep1: function() { + let vm = this; - if (val === 'approval') { - $('#admin_override_suspend_auto_approval').attr('disabled', false); - } else { - $('#admin_override_suspend_auto_approval').prop('checked', false); - $('#admin_override_suspend_auto_approval').attr('disabled', true); - } - }).triggerHandler('change'); + ret = {}; - let usdFeaturedPrice = $('.featured-price-usd').text(); - let ethFeaturedPrice; + if (vm.step1Submitted) { + if (!vm.form.experience_level) { + ret['experience_level'] = 'Please select the experience level'; + } + if (!vm.form.project_length) { + ret['project_length'] = 'Please select the project length'; + } - getAmountEstimate(usdFeaturedPrice, 'ETH', function(amountEstimate) { - ethFeaturedPrice = amountEstimate['value']; - $('.featured-price-eth').text(`+${amountEstimate['value']} ETH`); - }); + if (!vm.form.bounty_type) { + ret['bounty_type'] = 'Please select the bounty type'; + } else if (vm.form.bounty_type === 'Other') { + if (!vm.form.bounty_type_other) { + ret['bounty_type_other'] = 'Please describe your bounty type'; + } + } + + if (vm.form.keywords.length < 1) { + ret['keywords'] = 'Select at least one category'; + } + } - getSuggestions(); - $('#keywords').on('change', getSuggestions); + return ret; + }, - $('.select2-tag__choice').on('click', function() { - $('#invite-contributors.js-select2').data('select2').dataAdapter.select(processedData[0].children[$(this).data('id')]); - }); + checkFormStep2: function() { + let vm = this; - $('.select2-add_byskill').on('click', function(e) { - e.preventDefault(); - $('#invite-contributors.js-select2').val(usersBySkills.map((item) => { - return item.id; - })).trigger('change'); - }); + ret = {}; - $('.select2-clear_invites').on('click', function(e) { - e.preventDefault(); - $('#invite-contributors.js-select2').val(null).trigger('change'); - }); + if (vm.step2Submitted) { + if (!vm.form.bountyInformationSource) { + ret['bountyInformationSource'] = 'Select the bounty information source'; + } else if (vm.form.bountyInformationSource === 'github') { - const reservedForHandle = bounty && bounty.reserved_for_user_handle ? bounty.reserved_for_user_handle : []; + if (!vm.form.issueUrl) { + ret['issueDetails'] = 'Please input a GitHub issue'; + } else if (vm.errors.issueDetails) { + if (vm.errors.issueDetails) { + ret['issueDetails'] = vm.errors.issueDetails; + } + } - userSearch('#reservedFor', false, '', reservedForHandle, true); + } else { + if (!vm.form.title) { + ret['title'] = 'Please input bounty title'; + } - if ($('input[name=amount]').length) { + if (!vm.form.richDescriptionText.trim()) { + ret['description'] = 'Please input bounty description'; + } - const denomination = $('input[name=denomination]').val(); + if (vm.isHackathonBounty) { + if (!vm.form.organisationUrl) { + ret['organisationUrl'] = 'Please input a GitHub organization URL'; + } else if (vm.errors.orgDetails) { + if (vm.errors.orgDetails) { + ret['organisationUrl'] = vm.errors.orgDetails; + } + } + } - setTimeout(() => setUsdAmount(denomination, false), 1000); + } + } - $('input[name=hours]').keyup(() => setUsdAmount(denomination, false)); - $('input[name=hours]').blur(() => setUsdAmount(denomination, false)); - $('input[name=amount]').keyup(() => setUsdAmount(denomination, false)); + return ret; + }, - $('input[name=usd_amount]').on('focusin', function() { - $('input[name=usd_amount]').attr('prev_usd_amount', $(this).val()); - $('input[name=amount]').trigger('change'); - }); + checkFormStep3: function() { + let vm = this; - $('input[name=usd_amount]').on('focusout', function() { - $('input[name=usd_amount]').attr('prev_usd_amount', $(this).val()); - $('input[name=amount]').trigger('change'); - }); + ret = {}; - $('input[name=usd_amount]').keyup(() => { - const prev_usd_amount = $('input[name=usd_amount]').attr('prev_usd_amount'); - const usd_amount = $('input[name=usd_amount').val(); + if (vm.step3Submitted) { + if (!vm.chainId) { + ret['chainId'] = 'Please select a chain'; + } + + if (!vm.form.token) { + ret['token'] = 'Please select a token'; + } - $('input[name=amount]').trigger('change'); + if (vm.form.peg_to_usd) { + let amountusd = Number.parseFloat(vm.form.amountusd); - if (prev_usd_amount != usd_amount) { - usdToAmount(usd_amount, denomination); + if (!amountusd > 0) { + ret['amountusd'] = 'Please enter a valid anount'; + } + } else { + let amount = Number.parseFloat(vm.form.amount); + + if (!amount > 0) { + ret['amount'] = 'Please enter a valid anount'; + } + } } - }); - } - form.validate({ - errorPlacement: function(error, element) { - if (element.attr('name') == 'bounty_categories') { - error.appendTo($(element).parents('.btn-group-toggle').next('.cat-error')); + return ret; + }, + + checkFormStep4: function() { + let vm = this; + + ret = {}; + + if (vm.step4Submitted) { + if (!vm.form.project_type) { + ret['project_type'] = 'Select the project type'; + } + if (!vm.form.permission_type) { + ret['permission_type'] = 'Select the permission type'; + } + } + + return ret; + }, + + checkForm: async function() { + return true; + }, + web3Type() { + let vm = this; + let type; + + switch (vm.chainId) { + case '1': + // ethereum + type = 'web3_modal'; + break; + case '30': + // rsk + type = 'rsk_ext'; + break; + case '50': + // xinfin + type = 'xinfin_ext'; + break; + case '59': + case '58': + // 58 - polkadot, 59 - kusama + type = 'polkadot_ext'; + break; + case '56': + // binance + type = 'binance_ext'; + break; + case '1000': + // harmony + type = 'harmony_ext'; + break; + case '1995': + // nervos + type = 'nervos_ext'; + break; + case '1001': + // algorand + type = 'algorand_ext'; + break; + case '1935': + // sia + type = 'sia_ext'; + break; + case '50797': + // tezos + type = 'tezos_ext'; + break; + case '270895': + // casper + type = 'casper_ext'; + break; + case '1155': + // cosmos + type = 'cosmos_ext'; + break; + case '666': + // paypal + type = 'fiat'; + break; + case '0': // bitcoin + case '61': // ethereum classic + case '102': // zilliqa + case '600': // filecoin + case '42220': // celo mainnet + case '44786': // celo alfajores tesnet + case '717171': // other + type = 'qr'; + break; + default: + type = 'web3_modal'; + } + + vm.form.web3_type = type; + return type; + }, + getParams: async function() { + let vm = this; + + let params = new URLSearchParams(window.location.search); + + if (params.has('invite')) { + vm.expandedGroup.reserve = [1]; + } + + if (params.has('reserved')) { + vm.expandedGroup.reserve = [1]; + await vm.getUser(null, params.get('reserved'), true); + } + + let url; + + if (params.has('url')) { + url = params.get('url'); + vm.form.issueUrl = url; + vm.getIssueDetails(url); + } + + if (params.has('source')) { + url = params.get('source'); + vm.form.issueUrl = url; + vm.getIssueDetails(url); + } + }, + showQuickStart: function(force) { + let quickstartDontshow = localStorage['quickstart_dontshow'] ? JSON.parse(localStorage['quickstart_dontshow']) : false; + + if (quickstartDontshow !== true || force) { + fetch('/bounty/quickstart') + .then(function(response) { + return response.text(); + }).then(function(html) { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const guide = doc.querySelector('.btn-closeguide'); + + doc.querySelector('.show_video').href = 'https://www.youtube.com/watch?v=m1X0bDpVcf4'; + doc.querySelector('.show_video').target = '_blank'; + + guide.dataset.dismiss = 'modal'; + + const docArticle = doc.querySelector('.content').innerHTML; + const content = $.parseHTML( + ``); + + $(content).appendTo('body'); + document.getElementById('dontshow').checked = quickstartDontshow; + $('#gitcoin_updates').bootstrapModal('show'); + + $(document).on('change', '#dontshow', function(e) { + if ($(this)[0].checked) { + localStorage['quickstart_dontshow'] = true; + } else { + localStorage['quickstart_dontshow'] = false; + } + }); + }); + + $(document, '#gitcoin_updates').on('hidden.bs.modal', function(e) { + $('#gitcoin_updates').remove(); + $('#gitcoin_updates').bootstrapModal('dispose'); + }); + } + }, + isExpanded(key, type) { + return this.expandedGroup[type].indexOf(key) !== -1; + }, + toggleCollapse(key, type) { + if (this.isExpanded(key, type)) { + this.expandedGroup[type].splice(this.expandedGroup[type].indexOf(key), 1); } else { - error.insertAfter(element); + this.expandedGroup[type].push(key); + } + }, + updateDate(date) { + // date is expected to be a momentjs object + let vm = this; + + vm.form.expirationTimeDelta = date; + }, + updatePayoutDate(date) { + // date is expected to be a momentjs object + let vm = this; + + vm.form.payoutDate = date; + }, + userSearch(search, loading) { + let vm = this; + + if (search.length < 3) { + return; + } + loading(true); + vm.getUser(loading, search); + }, + getUser: async function(loading, search, selected) { + let vm = this; + let myHeaders = new Headers(); + let url = `/api/v0.1/users_search/?token=${currentProfile.githubToken}&term=${escape(search)}`; + + myHeaders.append('X-Requested-With', 'XMLHttpRequest'); + return new Promise(resolve => { + + fetch(url, { + credentials: 'include', + headers: myHeaders + }).then(res => { + res.json().then(json => { + vm.usersOptions = json; + if (selected) { + vm.$set(vm.form, 'reservedFor', vm.usersOptions[0].text); + } + resolve(); + }); + if (loading) { + loading(false); + } + }); + }); + }, + checkBlocked(org) { + let vm = this; + let blocked = vm.blockedUrls.toLocaleString().toLowerCase().split(','); + + return blocked.indexOf(org.toLowerCase()) > -1; + }, + featuredValue() { + let vm = this; + const apiUrlAmount = `/sync/get_amount?amount=${vm.usdFeaturedPrice}&denomination=USDT`; + const getAmountData = fetchData(apiUrlAmount, 'GET'); + + $.when(getAmountData).then(value => { + vm.ethFeaturedPrice = value[0].eth.toFixed(4); + + }).catch((err) => { + console.log(err); + }); + }, + /** + * Filters tokens by vm.networkId + * @param {*} tokens + * @returns {*} tokens + */ + filterByNetworkId: function(tokens) { + let vm = this; + + if (vm.networkId) { + tokens = tokens.filter((token) => { + return String(token.networkId) === vm.networkId; + }); } + return tokens; }, - ignore: '', - messages: { - select2Start: { - required: 'Please select the keyword tags for this bounty.' + submitForm: async function() { + let vm = this; + + + if (vm.form.organisationUrl) { + try { + let url = new URL(vm.form.organisationUrl); + let pathSegments = url.pathname.split('/'); + let orgName = pathSegments[pathSegments.length - 1] || pathSegments[pathSegments.length - 2]; + + vm.form.fundingOrganisation = orgName; + } catch (error) { + vm.form.fundingOrganisation = vm.form.organisationUrl; + } } + + const metadata = { + issueTitle: vm.form.title, + issueDescription: vm.form.bountyInformationSource == 'github' ? vm.form.issueDetails.description : vm.form.description, + issueKeywords: vm.form.keywords.join(), + githubUsername: vm.form.githubUsername, + notificationEmail: vm.form.notificationEmail, + fullName: vm.form.fullName, + experienceLevel: vm.form.experience_level, + projectLength: vm.form.project_length, + bountyType: vm.form.bounty_type, + estimatedHours: vm.form.hours, + fundingOrganisation: vm.form.fundingOrganisation, + eventTag: vm.form.eventTag, + is_featured: vm.form.featuredBounty ? '1' : undefined, + repo_type: 'public', + featuring_date: vm.form.featuredBounty && ((new Date().getTime() / 1000) | 0) || 0, + reservedFor: vm.form.reserved_for_user ? vm.form.reserved_for_user.text : '', + releaseAfter: '', + tokenName: vm.form.token.symbol, + invite: [], + bounty_categories: vm.form.bounty_categories.join(), + activity: '', + chain_id: vm.chainId + }; + + + const params = { + 'title': metadata.issueTitle, + 'amount': vm.form.amount, + 'value_in_token': vm.form.amount * 10 ** vm.form.token.decimals, + 'token_name': metadata.tokenName, + 'token_address': vm.form.token.address, + 'bounty_type': metadata.bountyType !== 'Other' ? metadata.bountyType : vm.form.bounty_type_other, + 'project_length': metadata.projectLength, + 'estimated_hours': metadata.estimatedHours, + 'experience_level': metadata.experienceLevel, + 'github_url': vm.form.issueUrl, + 'bounty_owner_email': metadata.notificationEmail, + 'bounty_owner_github_username': metadata.githubUsername, + 'bounty_owner_name': metadata.fullName, // ETC-TODO REMOVE ? + 'reserved_for_user_handle': metadata.reservedFor, + 'release_to_public': metadata.releaseAfter, + 'never_expires': vm.form.never_expires, + 'expires_date': vm.form.never_expires ? 9999999999 : moment(vm.form.expirationTimeDelta).utc().unix(), + 'payout_date': vm.form.never_expires ? 9999999999 : moment(vm.form.payoutDate).utc().unix(), + 'metadata': metadata, + 'raw_data': {}, // ETC-TODO REMOVE ? + 'network': vm.network, + 'issue_description': metadata.issueDescription, + 'funding_organisation': metadata.fundingOrganisation, + 'balance': vm.form.amount * 10 ** vm.form.token.decimals, // ETC-TODO REMOVE ? + 'project_type': vm.form.project_type, + 'permission_type': vm.form.permission_type, + 'bounty_categories': metadata.bounty_categories, + 'repo_type': metadata.repo_type, + 'is_featured': metadata.is_featured, + 'featuring_date': metadata.featuring_date, + 'fee_amount': vm.totalAmount.totalFee, + 'fee_tx_id': vm.form.feeTxId, + 'coupon_code': vm.form.couponCode, + 'privacy_preferences': { + show_email_publicly: vm.form.showEmailPublicly + }, + 'attached_job_description': vm.form.jobDescription, + 'eventTag': metadata.eventTag, + 'auto_approve_workers': vm.form.auto_approve_workers, + 'web3_type': vm.web3Type(), + 'activity': metadata.activity, + 'bounty_owner_address': vm.form.funderAddress, + 'acceptance_criteria': JSON.stringify(vm.form.richAcceptanceCriteria), + 'resources': JSON.stringify(vm.form.richResources), + 'contact_details': vm.nonEmptyContactDetails, + 'bounty_source': vm.form.bountyInformationSource, + 'peg_to_usd': vm.form.peg_to_usd, + 'amount_usd': vm.form.amountusd, + 'owners': vm.form.bounty_owners.map(owner => owner.id), + 'custom_issue_description': JSON.stringify(vm.form.richDescriptionContent) + }; + + vm.sendBounty(JSON.stringify(params)); + }, - submitHandler: function(form) { - const inputElements = $(form).find(':input'); - const formData = {}; + sendBounty(data) { + let vm = this; + + if (typeof ga !== 'undefined') { + ga('send', 'event', 'Create Bounty', 'click', 'Bounty Funder'); + } - inputElements.removeAttr('disabled'); - $.each($(form).serializeArray(), function() { - if (formData[this.name]) { - formData[this.name] += ',' + this.value; + const bountyId = document.pk; + const apiUrlBounty = '/bounty/change/' + bountyId; + const postBountyData = fetchData(apiUrlBounty, 'POST', data); + + $.when(postBountyData).then((responseMsg, responseStatus, response, a, b, c, d, e) => { + if (200 <= response.status && response.status <= 204) { + console.log('success', response); + removeEventListener('beforeunload', beforeUnloadListener, {capture: true}); + window.location.href = responseMsg.url; + } else if (response.status == 304) { + _alert('Bounty already exists for this github issue.', 'danger'); + console.error(`error: bounty creation failed with status: ${response.status} and message: ${response.message}`); } else { - formData[this.name] = this.value; + _alert(`Unable to create a bounty. ${response.message}`, 'danger'); + console.error(`error: bounty creation failed with status: ${response.status} and message: ${response.message}`); } + + }).catch((err) => { + console.log(err); + _alert('Unable to create a bounty. Please try again later', 'danger'); }); - inputElements.attr('disabled', 'disabled'); - loading_button($('.js-submit')); + }, + updateNav: function(direction) { + if (direction === 1) { + // Forward navigation + let errors = {}; + + switch (this.step) { + case 1: + this.step1Submitted = true; + errors = this.checkFormStep1(); + break; + case 2: + this.step2Submitted = true; + errors = this.checkFormStep2(); + break; + case 3: + this.step3Submitted = true; + errors = this.checkFormStep3(); + break; + case 4: + this.step4Submitted = true; + errors = this.checkFormStep4(); + break; + default: + this.submitForm(); + return; + } + if (Object.keys(errors).length == 0) { + this.step += 1; + } + } else if (this.step > 1) { + // Backward navigation + this.step -= 1; + } + }, + + removeContactDetails(idx) { + this.form.contactDetails.splice(idx, 1); + }, - const reservedFor = $('.username-search').select2('data')[0]; - let inviteContributors = $('#invite-contributors.js-select2').select2('data').map((user) => { - return user.user__profile__id; + addContactDetails() { + this.form.contactDetails.push({ + type: '', + value: '' }); + }, - if (reservedFor) { - formData['reserved_for_user_handle'] = reservedFor.text; - } + updateCustomDescription({text, delta}) { + this.form.richDescriptionText = text; + this.form.richDescriptionContent = delta; + }, + + updateAcceptanceCriteria({ text, delta }) { + this.form.richAcceptanceCriteria = delta; + this.form.richAcceptanceCriteriaText = text; + }, + + updateResources({ text, delta }) { + this.form.richResources = delta; + this.form.richResourcesText = text; + }, + + popover(elementId) { + $(elementId).popover({ + placement: 'right', + content: helpText[elementId] + }).popover('show'); + } + }, + computed: { + bountyFee: function() { + let vm = this; - if (inviteContributors.length) { - formData['invite'] = inviteContributors; + if (vm.chainId === '1' && !vm.subscriptionActive) { + return Number(document.FEE_PERCENTAGE); } + return 0; + + }, + totalAmount: function() { + let vm = this; + let fee = vm.bountyFee / 100.0; + + let totalFee = Number(vm.form.amount) * fee; + let total = Number(vm.form.amount) + totalFee; - if (document.result && document.result.is_featured) { - formData['is_featured'] = true; - } else if (formData['featuredBounty'] === '1') { - formData['is_featured'] = true; - formData['featuring_date'] = new Date().getTime() / 1000; + return { 'totalFee': totalFee, 'total': total }; + }, + totalTx: function() { + let vm = this; + let numberTx = 0; + + if (vm.chainId === '1' && !vm.subscriptionActive) { + numberTx += vm.bountyFee > 0 ? 1 : 0; } else { - formData['is_featured'] = false; + numberTx = 0; } - const token = tokenAddressToDetailsByNetwork(token_address, bounty_network); + if (!vm.subscriptionActive) { + numberTx += vm.form.featuredBounty ? 1 : 0; + } - formData['value_in_token'] = formData['amount'] * 10 ** token.decimals; + return numberTx; - const bountyId = document.pk; - const payload = JSON.stringify(formData); - - const payFeaturedBounty = function() { - indicateMetamaskPopup(); - web3.eth.sendTransaction({ - to: '0x88c62f1695DD073B43dB16Df1559Fda841de38c6', - from: selectedAccount, - value: web3.utils.toWei(String(ethFeaturedPrice)), - gasPrice: web3.utils.toHex(5 * Math.pow(10, 9)), - gas: web3.utils.toHex(318730), - gasLimit: web3.utils.toHex(318730) + }, + filterOrgSelected: function() { + if (!this.orgSelected) { + return; + } + return `/dynamic/avatar/${this.orgSelected}`; + }, + successRate: function() { + let rate; + + if (!this.form.amountusd) { + return; + } + + rate = ((this.form.amountusd / this.form.hours) * 100 / 120).toFixed(0); + if (rate > 100) { + rate = 100; + } + return rate; + + }, + sortByPriority: function() { + return this.tokens.sort(function(a, b) { + return b.priority - a.priority; + }); + }, + filterByNetwork: function() { + const vm = this; + + if (vm.network == '') { + return vm.sortByPriority; + } + return vm.sortByPriority.filter((item) => { + + return item.network.toLowerCase().indexOf(vm.network.toLowerCase()) >= 0; + }); + }, + filterByChainId: function() { + const vm = this; + let result; + + if (vm.chainId == '') { + result = vm.filterByNetwork; + } else { + result = vm.filterByNetwork.filter((item) => { + return String(item.chainId) === vm.chainId; + }); + + if (vm.chainId == '1') { + // allow only mainnet tokens in ETH chain + vm.networkId = '1'; + result = vm.filterByNetworkId(result); + } + } + return result; + }, + currentSteps: function() { + const steps = [ + { + text_long: 'Bounty Type', + text_short: 'Bounty Type', + active: false }, - function(error, result) { - indicateMetamaskPopup(true); - if (error) { - _alert({ message: gettext('Unable to upgrade to featured bounty. Please try again.') }, 'danger'); - console.log(error); - } else { - saveAttestationData( - result, - ethFeaturedPrice, - '0x88c62f1695DD073B43dB16Df1559Fda841de38c6', - 'featuredbounty' - ); - saveBountyChanges(); - } + { + text_long: 'Bounty Details', + text_short: 'Bounty Details', + active: false + }, + { + text_long: 'Payment Information', + text_short: 'Payment Info', + active: false + }, + { + text_long: 'Additional Information', + text_short: 'Additional Info', + active: false + }, + { + text_long: 'Review Bounty', + text_short: 'Review Bounty', + active: false + } + ]; + + steps[this.step - 1].active = true; + return steps; + }, + chainId: function() { + if (this.chain) { + return this.chain.id; + } + return ''; + }, + isExpired: function() { + return moment(this.form.expirationTimeDelta).isBefore(); + }, + expiresAfterAYear: function() { + if (this.form.never_expires) { + return true; + } + return moment().diff(this.form.expirationTimeDelta, 'years') < -1; + }, + isPayoutDateExpired: function() { + return moment(this.form.payoutDate).isBefore(); + }, + payoutDateExpiresAfterAYear: function() { + if (this.form.never_expires) { + return true; + } + return moment().diff(this.form.payoutDate, 'years') < -1; + }, + step1Errors: function() { + return this.checkFormStep1(); + }, + isStep1Valid: function() { + let ret = Object.keys(this.step1Errors).length == 0; + + return ret; + }, + step2Errors: function() { + return this.checkFormStep2(); + }, + isStep2Valid: function() { + let ret = Object.keys(this.step2Errors).length == 0; + + return ret; + }, + step3Errors: function() { + return this.checkFormStep3(); + }, + isStep3Valid: function() { + let ret = Object.keys(this.step3Errors).length == 0; + + return ret; + }, + step4Errors: function() { + return this.checkFormStep4(); + }, + isStep4Valid: function() { + let ret = Object.keys(this.step4Errors).length == 0; + + return ret; + }, + nonEmptyContactDetails: function() { + if (this.form.contactDetails) { + return this.form.contactDetails.filter(function(c) { + return !!c.value; }); - }; + } + return []; + + } + }, + watch: { + form: { + deep: true, + handler(newVal, oldVal) { + this.dirty = true; + } - const saveBountyChanges = function() { - $.post('/bounty/change/' + bountyId, payload).then( - function(result) { - inputElements.removeAttr('disabled'); - unloading_button($('.js-submit')); + }, + chain: async function(val) { - result = sanitizeAPIResults(result); - _alert({ message: result.msg }, 'success'); + if (val) { + // if (!provider && val.id === '1') { + // await onConnect(); + // } - if (result.url) { - setTimeout(function() { - document.location.href = result.url; - }, 1000); - } + // if (val.id === '56') { + // this.getBinanceSelectedAccount(); + // } + + this.getTokens(); + } + } + } +}); + +if (document.getElementById('gc-hackathon-new-bounty')) { + let bounty = document.result; + + appFormBounty = new Vue({ + delimiters: [ '[[', ']]' ], + el: '#gc-hackathon-new-bounty', + components: { + 'vue-select': 'vue-select' + }, + data() { + let isCommonBountyType = bountyTypes.find(function(bt) { + return bounty.bounty_type === bt; + }); + let ret = { + status: 'OPEN', + tokens: [], + network: bounty.network, + chain: null, + funderAddressFallback: false, + checkboxes: { 'terms': false, 'termsPrivacy': false, 'hiringRightNow': false }, + expandedGroup: { 'reserve': [], 'featuredBounty': [] }, + errors: {}, + usersOptions: [], + orgSelected: '', + subscriptions: document.subscriptions, + subscriptionActive: (document.subscriptions ? document.subscriptions.length : false) || document.contxt.is_pro, + coinValue: null, + usdFeaturedPrice: 12, + ethFeaturedPrice: null, + blockedUrls: document.blocked_urls, + dirty: false, + submitted: true, + step1Submitted: false, + step2Submitted: false, + step3Submitted: false, + step4Submitted: false, + reserveBounty: !!bounty.reserved_for_user_handle, + sponsors: document.sponsors, // Used only for hackathon + isHackathonBounty: document.isHackathonBounty, + hackathon_slug: document.hackathon ? document.hackathon.slug : null, + isNew: false, + form: { + eventTag: document.hackathon ? document.hackathon.name : '', + expirationTimeDelta: moment.unix(bounty.expires_date), + payoutDate: moment.unix(bounty.payout_date), + featuredBounty: false, + fundingOrganisation: bounty.funding_organisation, + issueDetails: {description: bounty.issue_description}, + issueUrl: bounty.github_url, + githubUsername: document.contxt.github_handle, + notificationEmail: document.contxt.email, + showEmailPublicly: '1', + auto_approve_workers: false, + fullName: document.contxt.name, + hours: '1', + bounty_categories: [], + project_type: bounty.project_type, + permission_type: bounty.permission_type, + keywords: bounty.keywords ? bounty.keywords.split(',') : [], + amount: bounty.value_true, + amountusd: bounty.value_true_usd, + peg_to_usd: bounty.peg_to_usd, + token: null, + terms: false, + termsPrivacy: false, + feeTxId: null, + couponCode: document.coupon_code, + tags: [], + bounty_type: isCommonBountyType ? bounty.bounty_type : 'Other', + bounty_type_other: !isCommonBountyType ? bounty.bounty_type : null, + bountyInformationSource: bounty.bounty_source, + contactDetails: (bounty.contact_details && bounty.contact_details.length > 0) ? bounty.contact_details : [{ + type: 'Discord', + value: '' + }], + richResources: bounty.resources ? JSON.parse(bounty.resources) : null, + richResourcesText: bounty.resources ? bounty.resources : null, + richAcceptanceCriteria: bounty.acceptance_criteria ? JSON.parse(bounty.acceptance_criteria) : null, + richAcceptanceCriteriaText: bounty.acceptance_criteria ? bounty.acceptance_criteria : null, + organisationUrl: bounty.funding_organisation ? 'https://github.com/' + bounty.funding_organisation : '', + title: bounty.title, + description: bounty.issue_description, + richDescription: bounty.issue_description, + bounty_owners: bounty.owners, + project_length: bounty.project_length, + experience_level: bounty.experience_level, + never_expires: bounty.never_expires, + reserved_for_user: { + text: bounty.bounty_reserved_for_user.handle, + avatar_url: bounty.bounty_reserved_for_user.avatar_url + }, + richDescriptionContent: bounty.custom_issue_description ? JSON.parse(bounty.custom_issue_description) : null, + richDescriptionText: bounty.custom_issue_description ? bounty.custom_issue_description : '' + }, + editorOptionPrio: { + modules: { + toolbar: [ + [ 'bold', 'italic', 'underline' ], + [{ 'align': [] }], + [{ 'list': 'ordered' }, { 'list': 'bullet' }], + [ 'link', 'code-block' ], + ['clean'] + ] + }, + theme: 'snow', + placeholder: 'Describe what your bounty is about', + readOnly: true + } + }; + + + return ret; + }, + mounted() { + let vm = this; + + this.getParams(); + this.showQuickStart(); + this.getTokens().then(function() { + + for (let i = 0; i < vm.networkOptions.length; i++) { + let chain = vm.networkOptions[i]; + + if (chain.id == bounty.metadata.chain_id) { + vm.chain = chain; } - ).fail( - function(result) { - inputElements.removeAttr('disabled'); - unloading_button($('.js-submit')); + } - const alertMsg = result && result.responseJSON ? - result.responseJSON.error : - 'Something went wrong. Please reload the page and try again.'; + for (let i = 0; i < vm.filterByChainId.length; i++) { + let token = vm.filterByChainId[i]; - _alert({ message: alertMsg }, 'danger'); + if (token.network == bounty.network && token.chainId == bounty.metadata.chain_id) { + vm.form.token = token; + break; } - ); - }; + } - if (formData['is_featured'] && !bounty.is_featured) { - payFeaturedBounty(); - } else { - saveBountyChanges(); - } + }); + this.featuredValue(); } }); - -}); +} diff --git a/app/assets/v2/js/pages/new_bounty.js b/app/assets/v2/js/pages/new_bounty.js index 2c637807927..05e98a11ec1 100644 --- a/app/assets/v2/js/pages/new_bounty.js +++ b/app/assets/v2/js/pages/new_bounty.js @@ -5,9 +5,191 @@ window.addEventListener('dataWalletReady', function(e) { appFormBounty.form.funderAddress = selectedAccount; }, false); +const helpText = { + '#new-bounty-acceptace-criteria': 'Check out great examples of acceptance criteria from some of our past successful bounties!' +}; + +const bountyTypes = [ + 'Bug', + 'Project', + 'Feature', + 'Security', + 'Improvement', + 'Design', + 'Docs', + 'Code review', + 'Other' +]; + +Vue.use(VueQuillEditor); Vue.component('v-select', VueSelect.VueSelect); + +Vue.component('quill-editor-ext', { + props: [ 'initial', 'options' ], + template: '#quill-editor-ext', + data() { + return { + }; + }, + methods: { + onUpdate: function(event) { + this.$emit('change', { + text: event.text, + delta: event.quill.getContents() + }); + } + }, + mounted() { + this.$refs.quillEditor.quill.setContents(this.initial); + } +}); + Vue.mixin({ + data() { + return { + step: 1, + bountyTypes: bountyTypes, + tagOptions: [ + 'JavaScript', + 'TypeScript', + 'HTML', + 'Solidity', + 'CSS', + 'Python', + 'React', + 'Ethereum', + 'Blockchain', + 'DeFi', + 'Shell', + 'web3', + 'Design', + 'NFT', + 'Rust', + 'Dockerfile', + 'Go', + 'Community', + 'dApp', + 'API', + 'Documentation', + 'DAO', + 'Smart contract', + 'UI/UX', + 'POAP' + ], + contactDetailsType: [ + 'Discord', + 'Telegram', + 'Email' + ], + contactDetailsPlaceholderMap: { + '': 'mydiscord#1234', + 'Discord': 'mydiscord#1234', + 'Telegram': 'mytelegramusername', + 'Email': 'my@email.com' + }, + networkOptions: [ + { + 'id': '1', + 'logo': static_url + 'v2/images/chains/ethereum.svg', + 'label': 'ETH' + }, + { + 'id': '0', + 'logo': static_url + 'v2/images/chains/bitcoin.svg', + 'label': 'BTC' + }, + { + 'id': '666', + 'logo': static_url + 'v2/images/chains/paypal.svg', + 'label': 'PayPal' + }, + { + 'id': '56', + 'logo': static_url + 'v2/images/chains/binance.svg', + 'label': 'Binance' + }, + { + 'id': '1000', + 'logo': static_url + 'v2/images/chains/harmony.svg', + 'label': 'Harmony' + }, + { + 'id': '58', + 'logo': static_url + 'v2/images/chains/polkadot.svg', + 'label': 'Polkadot' + }, + { + 'id': '59', + 'logo': static_url + 'v2/images/chains/kusama.svg', + 'label': 'Kusama' + }, + { + 'id': '61', + 'logo': static_url + 'v2/images/chains/ethereum-classic.svg', + 'label': 'ETC' + }, + { + 'id': '102', + 'logo': static_url + 'v2/images/chains/zilliqa.svg', + 'label': 'Zilliqa' + }, + { + 'id': '600', + 'logo': static_url + 'v2/images/chains/filecoin.svg', + 'label': 'Filecoin' + }, + { + 'id': '42220', + 'logo': static_url + 'v2/images/chains/celo.svg', + 'label': 'Celo' + }, + { + 'id': '30', + 'logo': static_url + 'v2/images/chains/rsk.svg', + 'label': 'RSK' + }, + { + 'id': '50', + 'logo': static_url + 'v2/images/chains/xinfin.svg', + 'label': 'Xinfin' + }, + { + 'id': '1001', + 'logo': static_url + 'v2/images/chains/algorand.svg', + 'label': 'Algorand' + }, + { + 'id': '1935', + 'logo': static_url + 'v2/images/chains/sia.svg', + 'label': 'Sia' + }, + { + 'id': '1995', + 'logo': static_url + 'v2/images/chains/nervos.svg', + 'label': 'Nervos' + }, + { + 'id': '50797', + 'logo': static_url + 'v2/images/chains/tezos.svg', + 'label': 'Tezos' + }, + { + 'id': '270895', + 'logo': static_url + 'v2/images/chains/casper.svg', + 'label': 'Casper' + }, + { + 'id': '717171', + 'logo': null, + 'label': 'Other' + } + ] + }; + }, methods: { + getContactDetailsPlaceholder: function(val) { + return this.contactDetailsPlaceholderMap[val] || this.contactDetailsPlaceholderMap['']; + }, estHoursValidator: function() { this.form.hours = parseFloat(this.form.hours || 0); this.form.hours = Math.ceil(this.form.hours); @@ -53,7 +235,11 @@ Vue.mixin({ } vm.$delete(vm.errors, 'issueDetails'); - const apiUrldetails = `/sync/get_issue_details?url=${encodeURIComponent(url.trim())}&duplicates=true&network=${vm.network}`; + let apiUrldetails = `/sync/get_issue_details?url=${encodeURIComponent(url.trim())}&duplicates=true&network=${vm.network}`; + + if (vm.hackathon_slug) { + apiUrldetails += `&hackathon_slug=${encodeURIComponent(vm.hackathon_slug)}`; + } vm.form.issueDetails = undefined; const getIssue = fetchData(apiUrldetails, 'GET'); @@ -64,7 +250,13 @@ Vue.mixin({ } vm.form.issueDetails = response; - // vm.$set(vm.errors, 'issueDetails', undefined); + + let md = window.markdownit(); + + vm.form.richDescription = md.render(vm.form.issueDetails.description); + vm.form.title = vm.form.issueDetails.title; + + vm.$set(vm.errors, 'issueDetails', undefined); }).catch((err) => { console.log(err); vm.form.issueDetails = undefined; @@ -72,6 +264,45 @@ Vue.mixin({ }); }, + validateOrgUrl: function(url) { + let vm = this; + + if (!url) { + vm.$set(vm.errors, 'orgDetails', undefined); + return; + } + + let ghIssueUrl; + + try { + ghIssueUrl = new URL(url); + } catch (e) { + vm.$set(vm.errors, 'orgDetails', 'Please paste a github org url'); + return; + } + + if (ghIssueUrl.host != 'github.com') { + vm.$set(vm.errors, 'orgDetails', 'Please paste a github org url'); + return; + } + + let apiUrldetails = `/sync/validate_org_url?url=${encodeURIComponent(url.trim())}`; + + if (vm.hackathon_slug) { + apiUrldetails += `&hackathon_slug=${encodeURIComponent(vm.hackathon_slug)}`; + } + + vm.form.orgDetails = undefined; + const getIssue = fetchData(apiUrldetails, 'GET'); + + $.when(getIssue).then((response) => { + vm.$set(vm.errors, 'orgDetails', undefined); + }).catch((err) => { + console.log(err); + vm.form.issueDetails = undefined; + vm.$set(vm.errors, 'orgDetails', err.responseJSON.message); + }); + }, getTokens: function() { let vm = this; const apiUrlTokens = '/api/v1/tokens/'; @@ -79,7 +310,7 @@ Vue.mixin({ $.when(getTokensData).then((response) => { vm.tokens = response; - vm.form.token = vm.filterByChainId[0]; + // vm.form.token = vm.filterByChainId[0]; vm.getAmount(vm.form.token.symbol); }).catch((err) => { @@ -96,6 +327,11 @@ Vue.mixin({ vm.funderAddressFallback = true; } }, + onChainInput: function() { + this.form.token = null; + this.form.amount = 0; + this.form.amountusd = 0; + }, getAmount: function(token) { let vm = this; @@ -107,8 +343,7 @@ Vue.mixin({ $.when(getAmountData).then(tokens => { vm.coinValue = tokens[0].usdt; - vm.calcValues('usd'); - + // vm.calcValues('usd'); }).catch((err) => { console.log(err); }); @@ -135,48 +370,129 @@ Vue.mixin({ return validateWalletAddress(vm.chainId, vm.form.funderAddress); }, - checkForm: async function(e) { + checkFormStep1: function() { let vm = this; - vm.submitted = true; - vm.errors = {}; + ret = {}; - if (!vm.form.keywords.length) { - vm.$set(vm.errors, 'keywords', 'Please select the prize keywords'); - } - if (!vm.form.experience_level || !vm.form.project_length || !vm.form.bounty_type) { - vm.$set(vm.errors, 'experience_level', 'Please select the details options'); - } - if (!vm.chainId) { - vm.$set(vm.errors, 'chainId', 'Please select an option'); - } - if (!vm.form.issueDetails || vm.form.issueDetails < 1) { - vm.$set(vm.errors, 'issueDetails', 'Please input a GitHub issue'); - } - if (vm.form.bounty_categories.length < 1) { - vm.$set(vm.errors, 'bounty_categories', 'Select at least one category'); - } - if (!vm.form.funderAddress) { - vm.$set(vm.errors, 'funderAddress', 'Fill the owner wallet address'); - } - if (!vm.validateFunderAddress()) { - vm.$set(vm.errors, 'funderAddress', `Please enter a valid ${vm.form.token.symbol} address`); - } - if (!vm.form.project_type) { - vm.$set(vm.errors, 'project_type', 'Select the project type'); - } - if (!vm.form.permission_type) { - vm.$set(vm.errors, 'permission_type', 'Select the permission type'); + if (vm.step1Submitted) { + if (!vm.form.experience_level) { + ret['experience_level'] = 'Please select the experience level'; + } + if (!vm.form.project_length) { + ret['project_length'] = 'Please select the project length'; + } + + if (!vm.form.bounty_type) { + ret['bounty_type'] = 'Please select the bounty type'; + } else if (vm.form.bounty_type === 'Other') { + if (!vm.form.bounty_type_other) { + ret['bounty_type_other'] = 'Please describe your bounty type'; + } + } + + if (vm.form.keywords.length < 1) { + ret['keywords'] = 'Select at least one category'; + } } - if (!vm.form.terms) { - vm.$set(vm.errors, 'terms', 'You need to accept the terms'); + + return ret; + }, + + checkFormStep2: function() { + let vm = this; + + ret = {}; + + if (vm.step2Submitted) { + if (!vm.form.bountyInformationSource) { + ret['bountyInformationSource'] = 'Select the bounty information source'; + } else if (vm.form.bountyInformationSource === 'github') { + + if (!vm.form.issueUrl) { + ret['issueDetails'] = 'Please input a GitHub issue'; + } else if (vm.errors.issueDetails) { + if (vm.errors.issueDetails) { + ret['issueDetails'] = vm.errors.issueDetails; + } + } + + } else { + if (!vm.form.title) { + ret['title'] = 'Please input bounty title'; + } + + if (!vm.form.richDescriptionText.trim()) { + ret['description'] = 'Please input bounty description'; + } + + if (vm.isHackathonBounty) { + if (!vm.form.organisationUrl) { + ret['organisationUrl'] = 'Please input a GitHub organization URL'; + } else if (vm.errors.orgDetails) { + if (vm.errors.orgDetails) { + ret['organisationUrl'] = vm.errors.orgDetails; + } + } + } + + } } - if (!vm.form.termsPrivacy) { - vm.$set(vm.errors, 'termsPrivacy', 'You need to accept the terms'); + + return ret; + }, + + checkFormStep3: function() { + let vm = this; + + ret = {}; + + if (vm.step3Submitted) { + if (!vm.chainId) { + ret['chainId'] = 'Please select a chain'; + } + + if (!vm.form.token) { + ret['token'] = 'Please select a token'; + } + + if (vm.form.peg_to_usd) { + let amountusd = Number.parseFloat(vm.form.amountusd); + + if (!amountusd > 0) { + ret['amountusd'] = 'Please enter a valid anount'; + } + } else { + let amount = Number.parseFloat(vm.form.amount); + + if (!amount > 0) { + ret['amount'] = 'Please enter a valid anount'; + } + } } - if (Object.keys(vm.errors).length) { - return false; + + return ret; + }, + + checkFormStep4: function() { + let vm = this; + + ret = {}; + + if (vm.step4Submitted) { + if (!vm.form.project_type) { + ret['project_type'] = 'Select the project type'; + } + if (!vm.form.permission_type) { + ret['permission_type'] = 'Select the permission type'; + } } + + return ret; + }, + + checkForm: async function() { + return true; }, web3Type() { let vm = this; @@ -345,10 +661,16 @@ Vue.mixin({ } }, updateDate(date) { + // date is expected to be a momentjs object let vm = this; - vm.form.expirationTimeDelta = date.format('MM/DD/YYYY'); + vm.form.expirationTimeDelta = date; + }, + updatePayoutDate(date) { + // date is expected to be a momentjs object + let vm = this; + vm.form.payoutDate = date; }, userSearch(search, loading) { let vm = this; @@ -358,7 +680,6 @@ Vue.mixin({ } loading(true); vm.getUser(loading, search); - }, getUser: async function(loading, search, selected) { let vm = this; @@ -447,7 +768,9 @@ Vue.mixin({ web3.eth.sendTransaction({ to: toAddress, from: selectedAccount, - value: BigInt(vm.totalAmount.totalFee.toFixed(18) * Math.pow(10, 18)).toString() + // the toFixed(0) at the end is for the rare cases where due to limitations in the number representation + // the result still contains decimal places + value: BigInt((vm.totalAmount.totalFee.toFixed(18) * Math.pow(10, 18)).toFixed(0)).toString() }).once('transactionHash', (txnHash, errors) => { console.log(txnHash, errors); @@ -470,7 +793,7 @@ Vue.mixin({ const amountAsString = new web3.utils.BN(BigInt(amountInWei)).toString(); const token_contract = new web3.eth.Contract(token_abi, vm.form.token.address); - token_contract.methods.transfer(toAddress, web3.utils.toHex(amountAsString)).send({from: selectedAccount}, + token_contract.methods.transfer(toAddress, web3.utils.toHex(amountAsString)).send({ from: selectedAccount }, function(error, txnId) { if (error) { _alert({ message: gettext('Unable to pay bounty fee. Please try again.') }, 'danger'); @@ -498,29 +821,39 @@ Vue.mixin({ } return tokens; }, - submitForm: async function(event) { - event.preventDefault(); + submitForm: async function() { let vm = this; - vm.checkForm(event); + if (!document.isHackathonBounty) { + if (!provider && vm.chainId === '1') { + onConnect(); + return false; + } - if (!provider && vm.chainId === '1') { - onConnect(); - return false; - } + if (vm.bountyFee > 0 && !vm.subscriptionActive) { + await vm.payFees(); + } - if (Object.keys(vm.errors).length) { - return false; - } - if (vm.bountyFee > 0 && !vm.subscriptionActive) { - await vm.payFees(); + if (vm.form.featuredBounty && !vm.subscriptionActive) { + await vm.payFeaturedBounty(); + } } - if (vm.form.featuredBounty && !vm.subscriptionActive) { - await vm.payFeaturedBounty(); + + if (vm.form.organisationUrl) { + try { + let url = new URL(vm.form.organisationUrl); + let pathSegments = url.pathname.split('/'); + let orgName = pathSegments[pathSegments.length - 1] || pathSegments[pathSegments.length - 2]; + + vm.form.fundingOrganisation = orgName; + } catch (error) { + vm.form.fundingOrganisation = vm.form.organisationUrl; + } } + const metadata = { - issueTitle: vm.form.issueDetails.title, - issueDescription: vm.form.issueDetails.description, + issueTitle: vm.form.title, + issueDescription: vm.form.bountyInformationSource == 'github' ? vm.form.issueDetails.description : vm.form.description, issueKeywords: vm.form.keywords.join(), githubUsername: vm.form.githubUsername, notificationEmail: vm.form.notificationEmail, @@ -549,7 +882,7 @@ Vue.mixin({ 'value_in_token': vm.form.amount * 10 ** vm.form.token.decimals, 'token_name': metadata.tokenName, 'token_address': vm.form.token.address, - 'bounty_type': metadata.bountyType, + 'bounty_type': metadata.bountyType !== 'Other' ? metadata.bountyType : vm.form.bounty_type_other, 'project_length': metadata.projectLength, 'estimated_hours': metadata.estimatedHours, 'experience_level': metadata.experienceLevel, @@ -559,11 +892,14 @@ Vue.mixin({ 'bounty_owner_name': metadata.fullName, // ETC-TODO REMOVE ? 'bounty_reserved_for': metadata.reservedFor, 'release_to_public': metadata.releaseAfter, - 'expires_date': vm.checkboxes.neverExpires ? 9999999999 : moment(vm.form.expirationTimeDelta).utc().unix(), + 'never_expires': vm.form.never_expires, + 'expires_date': vm.form.never_expires ? 9999999999 : moment(vm.form.expirationTimeDelta).utc().unix(), + 'payout_date': vm.form.never_expires ? 9999999999 : moment(vm.form.payoutDate).utc().unix(), 'metadata': JSON.stringify(metadata), 'raw_data': {}, // ETC-TODO REMOVE ? 'network': vm.network, 'issue_description': metadata.issueDescription, + 'custom_issue_description': JSON.stringify(vm.form.richDescriptionContent), 'funding_organisation': metadata.fundingOrganisation, 'balance': vm.form.amount * 10 ** vm.form.token.decimals, // ETC-TODO REMOVE ? 'project_type': vm.form.project_type, @@ -583,7 +919,14 @@ Vue.mixin({ 'auto_approve_workers': vm.form.auto_approve_workers, 'web3_type': vm.web3Type(), 'activity': metadata.activity, - 'bounty_owner_address': vm.form.funderAddress + 'bounty_owner_address': vm.form.funderAddress, + 'acceptance_criteria': JSON.stringify(vm.form.richAcceptanceCriteria), + 'resources': JSON.stringify(vm.form.richResources), + 'contact_details': JSON.stringify(vm.nonEmptyContactDetails), + 'bounty_source': vm.form.bountyInformationSource, + 'peg_to_usd': vm.form.peg_to_usd, + 'amount_usd': vm.form.amountusd, + 'owners': JSON.stringify(vm.form.bounty_owners.map(owner => owner.id)) }; vm.sendBounty(params); @@ -598,10 +941,11 @@ Vue.mixin({ const apiUrlBounty = '/api/v1/bounty/create'; const postBountyData = fetchData(apiUrlBounty, 'POST', data); - + $.when(postBountyData).then((response) => { if (200 <= response.status && response.status <= 204) { console.log('success', response); + removeEventListener('beforeunload', beforeUnloadListener, {capture: true}); window.location.href = response.bounty_url; } else if (response.status == 304) { _alert('Bounty already exists for this github issue.', 'danger'); @@ -616,24 +960,93 @@ Vue.mixin({ _alert('Unable to create a bounty. Please try again later', 'danger'); }); + }, + updateNav: function(direction) { + if (direction === 1) { + // Forward navigation + let errors = {}; + + switch (this.step) { + case 1: + this.step1Submitted = true; + errors = this.checkFormStep1(); + break; + case 2: + this.step2Submitted = true; + errors = this.checkFormStep2(); + break; + case 3: + this.step3Submitted = true; + errors = this.checkFormStep3(); + break; + case 4: + this.step4Submitted = true; + errors = this.checkFormStep4(); + break; + default: + this.submitForm(); + return; + } + if (Object.keys(errors).length == 0) { + this.step += 1; + } + } else if (this.step > 1) { + // Backward navigation + this.step -= 1; + } + }, + + removeContactDetails(idx) { + this.form.contactDetails.splice(idx, 1); + }, + + addContactDetails() { + this.form.contactDetails.push({ + type: '', + value: '' + }); + }, + + updateCustomDescription({text, delta}) { + this.form.richDescriptionText = text; + this.form.richDescriptionContent = delta; + }, + + updateAcceptanceCriteria({ text, delta }) { + this.form.richAcceptanceCriteria = delta; + this.form.richAcceptanceCriteriaText = text; + }, + + updateResources({ text, delta }) { + this.form.richResources = delta; + this.form.richResourcesText = text; + }, + + popover(elementId) { + $(elementId).popover({ + placement: 'right', + content: helpText[elementId] + }).popover('show'); } }, computed: { - totalAmount: function() { + bountyFee: function() { let vm = this; - let fee; if (vm.chainId === '1' && !vm.subscriptionActive) { - vm.bountyFee = document.FEE_PERCENTAGE; - fee = Number(vm.bountyFee) / 100.0; - } else { - vm.bountyFee = 0; - fee = 0; + return Number(document.FEE_PERCENTAGE); } + return 0; + + }, + totalAmount: function() { + let vm = this; + let fee = vm.bountyFee / 100.0; + let totalFee = Number(vm.form.amount) * fee; let total = Number(vm.form.amount) + totalFee; - return {'totalFee': totalFee, 'total': total }; + return { 'totalFee': totalFee, 'total': total }; }, totalTx: function() { let vm = this; @@ -683,7 +1096,7 @@ Vue.mixin({ if (vm.network == '') { return vm.sortByPriority; } - return vm.sortByPriority.filter((item)=>{ + return vm.sortByPriority.filter((item) => { return item.network.toLowerCase().indexOf(vm.network.toLowerCase()) >= 0; }); @@ -706,34 +1119,133 @@ Vue.mixin({ } } return result; + }, + currentSteps: function() { + const steps = [ + { + text_long: 'Bounty Type', + text_short: 'Bounty Type', + active: false + }, + { + text_long: 'Bounty Details', + text_short: 'Bounty Details', + active: false + }, + { + text_long: 'Payment Information', + text_short: 'Payment Info', + active: false + }, + { + text_long: 'Additional Information', + text_short: 'Additional Info', + active: false + }, + { + text_long: 'Review Bounty', + text_short: 'Review Bounty', + active: false + } + ]; + + steps[this.step - 1].active = true; + return steps; + }, + chainId: function() { + if (this.chain) { + return this.chain.id; + } + return ''; + }, + isExpired: function() { + return moment(this.form.expirationTimeDelta).isBefore(); + }, + expiresAfterAYear: function() { + if (this.form.never_expires) { + return true; + } + return moment().diff(this.form.expirationTimeDelta, 'years') < -1; + }, + isPayoutDateExpired: function() { + return moment(this.form.payoutDate).isBefore(); + }, + payoutDateExpiresAfterAYear: function() { + if (this.form.never_expires) { + return true; + } + return moment().diff(this.form.payoutDate, 'years') < -1; + }, + step1Errors: function() { + return this.checkFormStep1(); + }, + isStep1Valid: function() { + let ret = Object.keys(this.step1Errors).length == 0; + + return ret; + }, + step2Errors: function() { + return this.checkFormStep2(); + }, + isStep2Valid: function() { + let ret = Object.keys(this.step2Errors).length == 0; + + return ret; + }, + step3Errors: function() { + return this.checkFormStep3(); + }, + isStep3Valid: function() { + let ret = Object.keys(this.step3Errors).length == 0; + + return ret; + }, + step4Errors: function() { + return this.checkFormStep4(); + }, + isStep4Valid: function() { + let ret = Object.keys(this.step4Errors).length == 0; + + return ret; + }, + nonEmptyContactDetails: function() { + if (this.form.contactDetails) { + return this.form.contactDetails.filter(function(c) { + return !!c.value; + }); + } + return []; + } }, watch: { form: { deep: true, handler(newVal, oldVal) { - if (this.dirty && this.submitted) { - this.checkForm(); - } this.dirty = true; } - }, - chainId: async function(val) { - if (!provider && val === '1') { - await onConnect(); - } + chain: async function(val) { - if (val === '56') { - this.getBinanceSelectedAccount(); - } + if (val) { + // if (!provider && val.id === '1') { + // await onConnect(); + // } - this.getTokens(); + // if (val.id === '56') { + // this.getBinanceSelectedAccount(); + // } + + this.getTokens(); + } } } }); if (document.getElementById('gc-hackathon-new-bounty')) { + let expirationTimeDelta = moment().add(1, 'month'); + let payoutDate = expirationTimeDelta; + appFormBounty = new Vue({ delimiters: [ '[[', ']]' ], el: '#gc-hackathon-new-bounty', @@ -742,27 +1254,37 @@ if (document.getElementById('gc-hackathon-new-bounty')) { }, data() { return { - + status: 'OPEN', tokens: [], network: 'mainnet', - chainId: '', + chain: null, funderAddressFallback: false, - checkboxes: {'terms': false, 'termsPrivacy': false, 'neverExpires': true, 'hiringRightNow': false }, - expandedGroup: {'reserve': [], 'featuredBounty': []}, + checkboxes: { 'terms': false, 'termsPrivacy': false, 'hiringRightNow': false }, + expandedGroup: { 'reserve': [], 'featuredBounty': [] }, errors: {}, usersOptions: [], - bountyFee: document.FEE_PERCENTAGE, orgSelected: '', subscriptions: document.subscriptions, - subscriptionActive: document.subscriptions.length || document.contxt.is_pro, + subscriptionActive: (document.subscriptions ? document.subscriptions.length : false) || document.contxt.is_pro, coinValue: null, usdFeaturedPrice: 12, ethFeaturedPrice: null, blockedUrls: document.blocked_urls, dirty: false, - submitted: false, + submitted: true, + step1Submitted: false, + step2Submitted: false, + step3Submitted: false, + step4Submitted: false, + reserveBounty: null, + sponsors: document.sponsors, // USed only for hackathon + isHackathonBounty: document.isHackathonBounty, + hackathon_slug: document.hackathon ? document.hackathon.slug : null, + isNew: true, form: { - expirationTimeDelta: moment().add(1, 'month').format('MM/DD/YYYY'), + eventTag: document.hackathon ? document.hackathon.name : '', + expirationTimeDelta: expirationTimeDelta, + payoutDate: payoutDate, featuredBounty: false, fundingOrganisation: '', issueDetails: undefined, @@ -774,22 +1296,58 @@ if (document.getElementById('gc-hackathon-new-bounty')) { fullName: document.contxt.name, hours: '1', bounty_categories: [], - project_type: '', + project_type: document.isHackathonBounty ? 'multiple' : null, permission_type: '', keywords: [], - amount: 0.001, + amount: 0.0, amountusd: null, - token: {}, + peg_to_usd: true, + token: null, terms: false, termsPrivacy: false, feeTxId: null, - couponCode: document.coupon_code + couponCode: document.coupon_code, + tags: [], + bounty_type: null, + bountyInformationSource: null, + contactDetails: [{ + type: 'Discord', + value: '' + }], + richResources: '', + richResourcesText: '', + richAcceptanceCriteria: '', + richAcceptanceCriteriaText: '', + organisationUrl: '', + title: '', + description: '', + richDescription: '', + bounty_owners: [], + never_expires: false, + richDescriptionContent: null, + richDescriptionText: '' + }, + editorOptionPrio: { + modules: { + toolbar: [ + [ 'bold', 'italic', 'underline' ], + [{ 'align': [] }], + [{ 'list': 'ordered' }, { 'list': 'bullet' }], + [ 'link', 'code-block' ], + ['clean'] + ] + }, + theme: 'snow', + placeholder: 'Describe what your bounty is about', + readOnly: true } }; }, mounted() { this.getParams(); - this.showQuickStart(); + if (!document.isHackathonBounty) { + this.showQuickStart(); + } this.getTokens(); this.featuredValue(); } diff --git a/app/assets/v2/js/vue-components.js b/app/assets/v2/js/vue-components.js index 4538a806ac2..d191e95ccc5 100644 --- a/app/assets/v2/js/vue-components.js +++ b/app/assets/v2/js/vue-components.js @@ -787,25 +787,42 @@ Vue.component('suggested-profile', { Vue.component('date-range-picker', { template: '#date-range-template', + // date is expected to be a momentjs object props: [ 'date', 'disabled' ], data: function() { return { - newDate: this.date + newDate: this.date.format('MM/DD/YYYY') }; }, computed: { - pickDate() { - return this.newDate; + pickDate: { + // getter + get() { + return this.newDate; + }, + // setter + set(newValue) { + let vm = this; + let mDate = moment(newValue); + + vm.newDate = newValue; + vm.$emit('apply-daterangepicker', mDate); + } + } + }, + methods: { + $datepicker: function() { + return window.$(this.$el).find('input'); } }, mounted: function() { let vm = this; this.$nextTick(function() { - window.$(this.$el).daterangepicker({ + this.$datepicker().daterangepicker({ singleDatePicker: true, - startDate: moment().add(1, 'month'), + startDate: vm.newDate, alwaysShowCalendars: false, ranges: { '1 week': [ moment().add(7, 'days'), moment().add(7, 'days') ], diff --git a/app/assets/v2/scss/bounty.scss b/app/assets/v2/scss/bounty.scss index a73cad8a802..7a4743b7186 100644 --- a/app/assets/v2/scss/bounty.scss +++ b/app/assets/v2/scss/bounty.scss @@ -893,3 +893,38 @@ a.btn { .qrcode { display: inline-block; } + +.tag.bounty-category-tag { + background-color: $gc-violet-100; + color: $gc-violet-400; + border-radius: 10px; + font-size: 0.75rem; +} + +.tag.bounty-info-amount-usd { + background-color: var(--usd-bg); + color: var(--usd-color); + border-radius: 10px; + font-size: 0.85rem; +} + +.tag.bounty-info-amount-token { + background-color: $gc-violet-100; + color: $gc-violet-400; + border-radius: 10px; + font-size: 0.85rem; +} + +.bounty-info-payment-token { + font-size: 0.85rem; +} + +.bounty-info-payment-token i { + font-size: 11px; + top: -1px; + position: relative; +} + +.bounty-info-payment-token-name { + color: $gc-violet-400; +} diff --git a/app/assets/v2/scss/grants/form_wrapper.scss b/app/assets/v2/scss/grants/form_wrapper.scss index 1219fddddeb..3cf4939ca74 100644 --- a/app/assets/v2/scss/grants/form_wrapper.scss +++ b/app/assets/v2/scss/grants/form_wrapper.scss @@ -18,6 +18,50 @@ pointer-events: none; } + .new-border-top { + border-top: solid 1px $gc-grey-200; + border-left: solid 1px $gc-grey-200; + border-right: solid 1px $gc-grey-200; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + padding: 2.5rem 2.5rem 0 2.5rem; + > p { + margin: 0 0 12px 0; + } + > h4 { + padding: 0 0 24px 0; + } + } + + .new-border-mid { + border-left: solid 1px $gc-grey-200; + border-right: solid 1px $gc-grey-200; + margin-left: 0 !important; + margin-right: 0 !important; + padding: 0 2.5rem 0 2.5rem; + > p { + margin: 0 0 12px 0; + } + > h4 { + padding: 0 0 24px 0; + } + } + + .new-border-bottom { + border-left: solid 1px $gc-grey-200; + border-right: solid 1px $gc-grey-200; + border-bottom: solid 1px $gc-grey-200; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + padding: 0 2.5rem 2.5rem 2.5rem; + > p { + margin: 0 0 12px 0; + } + > h4 { + padding: 0 0 24px 0; + } + } + .navigation { margin-top: 50px; color: $gc-violet-400; @@ -48,6 +92,11 @@ padding-bottom: 12px; margin: 20px 0 10px 0; } + + .bounty-creation-help { + background-color: $gc-violet-100; + border-radius: 4px; + } } @media (max-width: 991.98px) { diff --git a/app/assets/v2/scss/submit_bounty.scss b/app/assets/v2/scss/submit_bounty.scss index 985897ecb5c..d4ef0605dbb 100644 --- a/app/assets/v2/scss/submit_bounty.scss +++ b/app/assets/v2/scss/submit_bounty.scss @@ -265,3 +265,13 @@ input:read-only { color: #0FCE7C; background: rgba(15, 206, 124, 0.2); } + +.bounty-type-label { + width: 120px; + display: block !important; +} + +.btn-radio.bounty-toggle-btn:hover:not(.disabled) { + background-color: #f9f9f9; + border-color: #3E00FF!important; +} diff --git a/app/dashboard/admin.py b/app/dashboard/admin.py index 418632c3aaf..0f58ef1d8cb 100644 --- a/app/dashboard/admin.py +++ b/app/dashboard/admin.py @@ -379,7 +379,7 @@ class BountyAdmin(admin.ModelAdmin): raw_id_fields = ['interested', 'coupon_code', 'org', 'event', 'bounty_owner_profile', 'bounty_reserved_for_user'] ordering = ['-id'] - search_fields = ['raw_data', 'title', 'bounty_owner_github_username', 'token_name'] + search_fields = ['raw_data', 'title', 'bounty_owner_github_username', 'token_name', 'custom_title', 'custom_description'] list_display = ['pk', 'img', 'bounty_state', 'idx_status', 'network_link', 'standard_bounties_id_link', 'bounty_link', 'what'] readonly_fields = [ 'what', 'img', 'fulfillments_link', 'standard_bounties_id_link', 'bounty_link', 'network_link', diff --git a/app/dashboard/helpers.py b/app/dashboard/helpers.py index 9d91b28bd76..5120efebec5 100644 --- a/app/dashboard/helpers.py +++ b/app/dashboard/helpers.py @@ -218,9 +218,33 @@ def issue_details(request): logger.warning(e) message = 'could not pull back remote response' return JsonResponse({'status':'false','message':message}, status=404) + return JsonResponse(response) +@ratelimit(key='ip', rate='50/m', method=ratelimit.UNSAFE, block=True) +def validate_org_url(request): + """Determine if the github org URL represents a valid bounty sponsor. + + Returns: + Empty response with status 200 if the url is valid + + """ + url = request.GET.get('url') + hackathon_slug = request.GET.get('hackathon_slug') + + if hackathon_slug: + sponsor_profiles = HackathonEvent.objects.filter(slug__iexact=hackathon_slug).prefetch_related('sponsor_profiles').values_list('sponsor_profiles__handle', flat=True) + sponsor_profiles = list(sponsor_profiles) + org_issue = org_name(url).lower() + + if org_issue not in sponsor_profiles: + message = 'This GitHub URL is not for a valid sponsor' + return JsonResponse({'status':'false','message':message}, status=404) + + return JsonResponse({'status': 'true'}) + + def normalize_url(url): """Normalize the URL. diff --git a/app/dashboard/management/commands/determine_bounties_never_expires_field.py b/app/dashboard/management/commands/determine_bounties_never_expires_field.py new file mode 100644 index 00000000000..4ac9d0d1a73 --- /dev/null +++ b/app/dashboard/management/commands/determine_bounties_never_expires_field.py @@ -0,0 +1,54 @@ +''' + Copyright (C) 2018 Gitcoin Core + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +''' + +from django.core.management.base import BaseCommand + +from dashboard.models import Bounty + + +class Command(BaseCommand): + + help = """This will set the `never_expire` flag to true on each bounty that meets the following criteria: + - never_expire - is false + - expires_date - is far into the future (year > 2200) + + """ + + def add_arguments(self , parser): + parser.add_argument('--exec', action='store_true') + + def handle(self, *args, **options): + do_exec = options["exec"] + if not do_exec: + print(""" +**************************************************************************************** +* This is a dry run, no processes will be killed +* In order to kill the processes re-run this command with the '--exec' option +**************************************************************************************** +""") + + bounties = Bounty.objects.all() + + for bounty in bounties: + print("checkin bounty -- date: %s, never_expires=%s bounty summary: %s" % (bounty.expires_date, bounty.never_expires, bounty)) + if bounty.expires_date.year > 2200 and not bounty.never_expires: + print(" -> setting never_expires flag to True") + + if do_exec: + bounty.never_expires = True + bounty.save() diff --git a/app/dashboard/migrations/0203_auto_20220518_0612.py b/app/dashboard/migrations/0203_auto_20220518_0612.py new file mode 100644 index 00000000000..a9d47d310b7 --- /dev/null +++ b/app/dashboard/migrations/0203_auto_20220518_0612.py @@ -0,0 +1,89 @@ +# Generated by Django 2.2.24 on 2022-05-18 06:12 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0202_hackathonevent_discord_server'), + ] + + operations = [ + migrations.AddField( + model_name='bounty', + name='acceptance_criteria', + field=models.TextField(blank=True, default='', help_text='Acceptance criteria', null=True), + ), + migrations.AddField( + model_name='bounty', + name='bounty_source', + field=models.CharField(choices=[('github', 'Github'), ('custom', 'Custom')], db_index=True, default='github', max_length=50), + ), + migrations.AddField( + model_name='bounty', + name='contact_details', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict, null=True), + ), + migrations.AddField( + model_name='bounty', + name='custom_issue_description', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='bounty', + name='never_expires', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='bounty', + name='owners', + field=models.ManyToManyField(blank=True, to='dashboard.Profile'), + ), + migrations.AddField( + model_name='bounty', + name='payout_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='bounty', + name='peg_to_usd', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='bounty', + name='resources', + field=models.TextField(blank=True, default='', help_text='Resources', null=True), + ), + migrations.AddField( + model_name='bounty', + name='usd_pegged_value_in_token', + field=models.DecimalField(blank=True, decimal_places=2, default=0, max_digits=50, null=True), + ), + migrations.AddField( + model_name='bounty', + name='usd_pegged_value_in_token_now', + field=models.DecimalField(blank=True, decimal_places=2, default=0, max_digits=50, null=True), + ), + migrations.AddField( + model_name='bounty', + name='value_true_usd', + field=models.DecimalField(blank=True, decimal_places=2, default=0, max_digits=50, null=True), + ), + migrations.AlterField( + model_name='bounty', + name='bounty_type', + field=models.CharField(blank=True, choices=[('Bug', 'Bug'), ('Project', 'Project'), ('Feature', 'Feature'), ('Security', 'Security'), ('Improvement', 'Improvement'), ('Design', 'Design'), ('Docs', 'Docs'), ('Code review', 'Code review'), ('Other', 'Other'), ('Unknown', 'Unknown')], db_index=True, max_length=50), + ), + migrations.AlterField( + model_name='bounty', + name='github_url', + field=models.URLField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name='bounty', + name='project_type', + field=models.CharField(choices=[('traditional', 'traditional'), ('contest', 'contest - deprecated'), ('cooperative', 'cooperative - deprecated'), ('multiple', 'multiple')], db_index=True, default='traditional', max_length=50), + ), + ] diff --git a/app/dashboard/models.py b/app/dashboard/models.py index ce6763ce148..5b2a4b3aea6 100644 --- a/app/dashboard/models.py +++ b/app/dashboard/models.py @@ -223,8 +223,9 @@ class Bounty(SuperModel): ] PROJECT_TYPES = [ ('traditional', 'traditional'), - ('contest', 'contest'), - ('cooperative', 'cooperative'), + ('contest', 'contest - deprecated'), + ('cooperative', 'cooperative - deprecated'), + ('multiple', 'multiple'), ] BOUNTY_CATEGORIES = [ ('frontend', 'frontend'), @@ -235,10 +236,17 @@ class Bounty(SuperModel): ] BOUNTY_TYPES = [ ('Bug', 'Bug'), - ('Security', 'Security'), + ('Project', 'Project'), ('Feature', 'Feature'), + ('Security', 'Security'), + ('Improvement', 'Improvement'), + ('Design', 'Design'), + ('Docs', 'Docs'), + ('Code review', 'Code review'), + ('Other', 'Other'), ('Unknown', 'Unknown'), ] + EXPERIENCE_LEVELS = [ ('Beginner', 'Beginner'), ('Intermediate', 'Intermediate'), @@ -299,6 +307,12 @@ class Bounty(SuperModel): ('manual', 'Manual') ) + + BOUNTY_SOURCES = ( + ('github', 'Github'), + ('custom', 'Custom'), + ) + bounty_state = models.CharField(max_length=50, choices=BOUNTY_STATES, default='open', db_index=True) web3_type = models.CharField(max_length=50, choices=WEB3_TYPES, default='bounties_network') title = models.CharField(max_length=1000) @@ -310,7 +324,7 @@ class Bounty(SuperModel): project_length = models.CharField(max_length=50, choices=PROJECT_LENGTHS, blank=True, db_index=True) estimated_hours = models.PositiveIntegerField(blank=True, null=True) experience_level = models.CharField(max_length=50, choices=EXPERIENCE_LEVELS, blank=True, db_index=True) - github_url = models.URLField(db_index=True) + github_url = models.URLField(db_index=True, blank=True, null=True) github_issue_details = JSONField(default=dict, blank=True, null=True) github_comments = models.IntegerField(default=0) bounty_owner_address = models.CharField(max_length=100, blank=True, null=True, db_index=True) @@ -330,6 +344,8 @@ class Bounty(SuperModel): reserved_for_user_expiration = models.DateTimeField(blank=True, null=True) is_open = models.BooleanField(db_index=True, help_text=_('Whether the bounty is still open for fulfillments.')) expires_date = models.DateTimeField() + payout_date = models.DateTimeField(null=True, blank=True) + never_expires = models.BooleanField(default=False) raw_data = JSONField(blank=True) metadata = JSONField(default=dict, blank=True) current_bounty = models.BooleanField( @@ -377,6 +393,12 @@ class Bounty(SuperModel): value_in_usdt = models.DecimalField(default=0, decimal_places=2, max_digits=50, blank=True, null=True) value_in_eth = models.DecimalField(default=0, decimal_places=2, max_digits=50, blank=True, null=True) value_true = models.DecimalField(default=0, decimal_places=2, max_digits=50, blank=True, null=True) + + usd_pegged_value_in_token_now = models.DecimalField(default=0, decimal_places=2, max_digits=50, blank=True, null=True) # The calculated amount in token, corresponding to value_true_usd + usd_pegged_value_in_token = models.DecimalField(default=0, decimal_places=2, max_digits=50, blank=True, null=True) # The calculated amount in token, corresponding to value_true_usd + value_true_usd = models.DecimalField(default=0, decimal_places=2, max_digits=50, blank=True, null=True) # The value the user wants to pay in USD + peg_to_usd = models.BooleanField(default=False) # True if the amount to pay should be pegged to USD + privacy_preferences = JSONField(default=dict, blank=True) admin_override_and_hide = models.BooleanField( default=False, help_text=_('Admin override to hide the bounty from the system') @@ -405,6 +427,22 @@ class Bounty(SuperModel): # Bounty QuerySet Manager objects = BountyQuerySet.as_manager() + contact_details = JSONField(default=dict, blank=True, null=True) + bounty_source = models.CharField(max_length=50, choices=BOUNTY_SOURCES, default='github', db_index=True) + + # acceptance criteria + acceptance_criteria = models.TextField(default='', blank=True, null=True, help_text=_('Acceptance criteria')) + + # resources + resources = models.TextField(default='', blank=True, null=True, help_text=_('Resources')) + + # Allow multiple owners + # This contains a list of IDs to dashboard.Profile + owners = models.ManyToManyField("Profile", blank=True) + + # Issue description for custom bounties, not related to a GitHUB issue + custom_issue_description = models.TextField(default='', blank=True) + class Meta: """Define metadata associated with Bounty.""" @@ -516,13 +554,7 @@ def get_relative_url(self, preceding_slash=True): str: The relative URL for the Bounty. """ - try: - _org_name = org_name(self.github_url) - _issue_num = int(issue_number(self.github_url)) - _repo_name = repo_name(self.github_url) - return f"{'/' if preceding_slash else ''}issue/{_org_name}/{_repo_name}/{_issue_num}/{self.standard_bounties_id}" - except Exception: - return f"{'/' if preceding_slash else ''}funding/details?url={self.github_url}" + return f"{'/' if preceding_slash else ''}issue/{self.id}" def get_canonical_url(self): """Get the canonical URL of the Bounty for SEO purposes. @@ -531,10 +563,7 @@ def get_canonical_url(self): str: The canonical URL of the Bounty. """ - _org_name = org_name(self.github_url) - _repo_name = repo_name(self.github_url) - _issue_num = int(issue_number(self.github_url)) - return settings.BASE_URL.rstrip('/') + reverse('issue_details_new2', kwargs={'ghuser': _org_name, 'ghrepo': _repo_name, 'ghissue': _issue_num}) + return settings.BASE_URL.rstrip('/') + reverse('issue_details_new4', kwargs={'bounty_id': self.id}) def get_natural_value(self): if not self.value_in_token: @@ -679,7 +708,11 @@ def org_display_name(self): # TODO: Remove POST ORGS @property def github_org_name(self): try: - return org_name(self.github_url) + if self.bounty_source == "github": + return org_name(self.github_url) + elif self.funding_organisation: + return self.funding_organisation + return None except Exception: return None @@ -858,6 +891,14 @@ def get_value_true(self): @property def get_value_in_eth(self): + if self.peg_to_usd: + if self.token_name == 'ETH': + return self.value_in_token / 10**18 + try: + return convert_amount(self.value_true, 'USDT', 'ETH') + except Exception: + return None + if self.token_name == 'ETH': return self.value_in_token / 10**18 try: @@ -867,20 +908,61 @@ def get_value_in_eth(self): @property def get_value_in_usdt_now(self): + if self.peg_to_usd: + return self.value_true_usd return self.value_in_usdt_at_time(None) @property def get_value_in_usdt(self): + if self.peg_to_usd: + return self.value_true_usd if self.status in self.OPEN_STATUSES: return self.value_in_usdt_now return self.value_in_usdt_then + @property + def get_usd_pegged_value_in_token_now(self): + if self.peg_to_usd: + try: + return self.usd_pegged_value_in_token_at_time(None) + except Exception: + return None + return self.value_true + + @property + def get_usd_pegged_value_in_token(self): + if self.peg_to_usd: + if self.status in self.OPEN_STATUSES: + try: + return self.usd_pegged_value_in_token_now + except Exception: + return None + return self.usd_pegged_value_in_token_then + return self.value_true + + @property + def usd_pegged_value_in_token_then(self): + return self.usd_pegged_value_in_token_at_time(self.web3_created) + + def usd_pegged_value_in_token_at_time(self, at_time): + if self.token_name in ['USDT', 'USDC']: + return self.value_true_usd + if self.token_name in settings.STABLE_COINS: + return self.value_true_usd + try: + return convert_amount(self.value_true_usd, 'USDT', self.token_name) + except ConversionRateNotFoundError: + try: + in_eth = convert_amount(self.value_true, 'USDT', 'ETH', at_time) + return convert_amount(in_eth, 'ETH', self.token_name, at_time) + except ConversionRateNotFoundError: + return None + @property def value_in_usdt_then(self): return self.value_in_usdt_at_time(self.web3_created) def value_in_usdt_at_time(self, at_time): - decimals = 10 ** 18 if self.token_name in ['USDT', 'USDC']: return float(self.value_in_token / 10 ** 6) if self.token_name in settings.STABLE_COINS: @@ -896,6 +978,8 @@ def value_in_usdt_at_time(self, at_time): @property def token_value_in_usdt_now(self): + if self.peg_to_usd: + return self.value_true_usd if self.token_name in settings.STABLE_COINS: return 1 try: @@ -905,6 +989,8 @@ def token_value_in_usdt_now(self): @property def token_value_in_usdt_then(self): + if self.peg_to_usd: + return self.value_true_usd try: return round(convert_token_to_usdt(self.token_name, self.web3_created), 2) except ConversionRateNotFoundError: @@ -2062,13 +2148,15 @@ def psave_bounty(sender, instance, **kwargs): 'Months': 5, } - instance.github_url = instance.github_url.lower() - try: - handle = instance.github_url.split('/')[3] - if not instance.org: - instance.org = Profile.objects.get(handle=handle) - except: - pass + if instance.github_url: + instance.github_url = instance.github_url.lower() + try: + handle = instance.github_url.split('/')[3] + if not instance.org: + instance.org = Profile.objects.get(handle=handle) + except: + pass + instance.idx_status = instance.status instance.fulfillment_accepted_on = instance.get_fulfillment_accepted_on instance.fulfillment_submitted_on = instance.get_fulfillment_submitted_on @@ -2084,6 +2172,9 @@ def psave_bounty(sender, instance, **kwargs): instance.value_in_eth = instance.get_value_in_eth instance.value_true = instance.get_value_true + instance.usd_pegged_value_in_token_now = instance.get_usd_pegged_value_in_token_now + instance.usd_pegged_value_in_token = instance.get_usd_pegged_value_in_token + # https://gitcoincore.slack.com/archives/CAXQ7PT60/p1600019142065700 if not instance.value_true: instance.value_true = 0 @@ -4257,7 +4348,7 @@ def get_who_works_with(self, work_type='collected', network='mainnet', bounties= if work_type != 'org': github_urls = bounties.values_list('github_url', flat=True) - profiles = [org_name(url) for url in github_urls] + profiles = [org_name(url) for url in github_urls if url] # github_url will be empty for custom bounties profiles = [ele for ele in profiles if ele] else: profiles = self.as_dict.get('orgs_bounties_works_with', []) diff --git a/app/dashboard/router.py b/app/dashboard/router.py index 2087903bd80..d2211b917ac 100644 --- a/app/dashboard/router.py +++ b/app/dashboard/router.py @@ -129,6 +129,22 @@ class Meta: model = Interest fields = ('pk', 'profile', 'created', 'pending', 'issue_message') +class ProfileSerializer(serializers.HyperlinkedModelSerializer): + avatar_url = serializers.SerializerMethodField() + + def get_avatar_url(self, obj): + ret = obj.avatar_url + if obj.avatar_baseavatar_related.filter(active=True).exists(): + # profile_json['avatar_id'] = user.avatar_baseavatar_related.filter(active=True).first().pk + ret = obj.avatar_baseavatar_related.filter(active=True).first().avatar_url + return ret + + class Meta: + + """Define the profile serializer metadata.""" + model = Profile + fields = ('id', 'handle', 'avatar_url') + # Serializers define the API representation. class BountySerializer(serializers.HyperlinkedModelSerializer): @@ -140,6 +156,8 @@ class BountySerializer(serializers.HyperlinkedModelSerializer): event = HackathonEventSerializer(many=False) bounty_owner_email = serializers.SerializerMethodField('override_bounty_owner_email') bounty_owner_name = serializers.SerializerMethodField('override_bounty_owner_name') + owners = ProfileSerializer(many=True, read_only=True) + bounty_owner_profile = ProfileSerializer() def override_bounty_owner_email(self, obj): can_make_visible_via_api = bool(int(obj.privacy_preferences.get('show_email_publicly', 0))) @@ -170,7 +188,10 @@ class Meta: 'attached_job_description', 'needs_review', 'github_issue_state', 'is_issue_closed', 'additional_funding_summary', 'funding_organisation', 'paid', 'event', 'admin_override_suspend_auto_approval', 'reserved_for_user_handle', 'is_featured', - 'featuring_date', 'repo_type', 'funder_last_messaged_on', 'can_remarket', 'is_reserved' + 'featuring_date', 'repo_type', 'funder_last_messaged_on', 'can_remarket', 'is_reserved', + 'contact_details', 'usd_pegged_value_in_token_now', 'usd_pegged_value_in_token', + 'value_true_usd', 'peg_to_usd', 'owners', 'payout_date', 'acceptance_criteria', 'resources', + 'bounty_source', 'bounty_owner_profile', 'never_expires', 'custom_issue_description' ) def create(self, validated_data): @@ -316,7 +337,7 @@ class Meta: 'fulfillment_started_on', 'fulfillment_submitted_on', 'canceled_on', 'web3_created', 'bounty_owner_address', 'avatar_url', 'network', 'standard_bounties_id', 'github_org_name', 'interested_count', 'token_name', 'value_in_usdt', 'keywords', 'value_in_token', 'project_type', 'is_open', 'expires_date', 'latest_activity', 'token_address', - 'bounty_categories' + 'bounty_categories', 'value_true_usd', 'peg_to_usd' ) diff --git a/app/dashboard/templates/bounty/bounty_progress_bar.html b/app/dashboard/templates/bounty/bounty_progress_bar.html new file mode 100644 index 00000000000..88530b61706 --- /dev/null +++ b/app/dashboard/templates/bounty/bounty_progress_bar.html @@ -0,0 +1,24 @@ + + + diff --git a/app/dashboard/templates/bounty/change.html b/app/dashboard/templates/bounty/change.html index c19c2701546..00f3af0b9e1 100644 --- a/app/dashboard/templates/bounty/change.html +++ b/app/dashboard/templates/bounty/change.html @@ -21,7 +21,17 @@ {% include 'shared/head.html' %} {% include 'shared/cards.html' %} {% bundle css file bounty_change %} + + + + + + + + + + {% endbundle %} @@ -31,93 +41,112 @@ {% include 'shared/top_nav.html' with class='d-md-flex' %} {% include 'shared/nav.html' %} -
-
-
-
-
-
-
-

{% trans "Change Bounty Details" %}

- -
- {% include 'shared/issue_details.html' %} -
- -
- {% include 'shared/bounty_details.html' %} -
- -
- {% include 'shared/bounty_categories.html' %} -
- -
- {% include 'shared/bounty_keywords.html' %} -
- -
- {% include 'shared/reserved.html' %} -
- - {% if is_bounties_network %} -
- {% include 'shared/featured.html' %} -
- {% endif %} - - {% if not is_bounties_network %} -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- {% endif %} +
+
+ {% include './bounty_progress_bar.html' %} +
+
+

Need help? Check out our Funder + Guide

+
-
- -
+ +
+ + {% include './new_bounty_step_1.html' %} + {% include './new_bounty_step_2.html' %} + {% include './new_bounty_step_3.html' %} + {% include './new_bounty_step_4.html' %} + {% include './new_bounty_step_5.html' %} - -
-
-
-
+
+ + + + {% include 'shared/footer.html' %} {% include 'shared/footer_scripts.html' with slim=1 %} {% include 'shared/current_profile.html' %} + {% include 'grants/components/form_wrapper.html' %}
+ + + + + + {% bundle merge_js file change_bounty_libs %} + + + {% endbundle %} + diff --git a/app/dashboard/templates/bounty/details2.html b/app/dashboard/templates/bounty/details2.html index 8aa4f6e9d5d..1ab979ae39f 100644 --- a/app/dashboard/templates/bounty/details2.html +++ b/app/dashboard/templates/bounty/details2.html @@ -56,36 +56,52 @@
-
+ +
Hackathon: [[bounty.event.name]]

[[ bounty.title ]]

-
+
+

[[keyword]]

+
+
+
+
+ +
+
+ + +
-
@@ -98,15 +114,6 @@

[[ bounty.title ]

-
- {% trans "Time left" %} - - Expired - More than a year - -
{% trans "Opened" %}
+
+
+ {% trans "Submission Cutoff Date" %} + Never expires + Expired + More than a year + +
+ +
+ {% trans "Estimated Payout Date" %} + Expired + More than a year + +
+
+ +
+ {% trans "Bounty Owners" %} + + +
+
+
+ +
+ [[bounty.bounty_owner_profile.handle]] +
+
+
+ +
+ [[owner.handle]] +
+
+
+ +
+ {% trans "Contacts" %} + +
+
+ [[contact.value]] + [[contact.value]] + [[contact.value]] +
+
+
+
- -