diff --git a/app/javascript/donate-button/donate-button.v2.js b/app/javascript/donate-button/donate-button.v2.ts similarity index 92% rename from app/javascript/donate-button/donate-button.v2.js rename to app/javascript/donate-button/donate-button.v2.ts index d9a877ab..7fa3a682 100644 --- a/app/javascript/donate-button/donate-button.v2.js +++ b/app/javascript/donate-button/donate-button.v2.ts @@ -1,3 +1,5 @@ +import { NotifyReporter } from "@jest/reporters"; + const donate_css = require('../../assets/stylesheets/donate-button/donate-button.v2.css'); const iframeHost = 'https://us.commitchange.com' @@ -10,13 +12,14 @@ function on_ios11() { return has11 && hasMacOS; } - window.commitchange = { + const windowAsAny = window as any; + windowAsAny.commitchange = { iframes: [] , modalIframe: null } - - commitchange.getParamsFromUrl = (whitelist) => { - var result = {}, + const commitchange = windowAsAny.commitchange; + commitchange.getParamsFromUrl = (whitelist:any) => { + var result:any = {}, tmp = []; var items = location.search.substr(1).split("&"); for (var index = 0; index < items.length; index++) { @@ -26,8 +29,8 @@ function on_ios11() { return result; } - commitchange.openDonationModal = (iframe, overlay) => { - return (event) => { + commitchange.openDonationModal = (iframe:HTMLIFrameElement, overlay:HTMLElement) => { + return (event:Event) => { overlay.className = 'commitchange-overlay commitchange-open' iframe.className = 'commitchange-iframe commitchange-open' if (on_ios11()) { @@ -44,7 +47,7 @@ function on_ios11() { } // Dynamically set the params of the appended iframe donate window - commitchange.setParams = (params, iframe) => { + commitchange.setParams = (params:any, iframe:HTMLIFrameElement) => { params.command = 'setDonationParams' params.sender = 'commitchange' iframe.contentWindow.postMessage(JSON.stringify(params), fullHost) @@ -69,7 +72,7 @@ function on_ios11() { return div } - commitchange.createIframe = (source) => { + commitchange.createIframe = (source:string) => { let i = document.createElement('iframe') const url = document.location.href i.setAttribute('class', 'commitchange-closed commitchange-iframe') @@ -79,8 +82,8 @@ function on_ios11() { // Given a button with a bunch of data parameters // return an object of key/vals corresponing to each param - commitchange.getParamsFromButton = (elem) => { - let options = { + commitchange.getParamsFromButton = (elem:HTMLElement) => { + let options: {[props:string]:any} = { offsite: 't' , type: elem.getAttribute('data-type') , custom_amounts: elem.getAttribute('data-custom-amounts') || elem.getAttribute('data-amounts') @@ -126,7 +129,7 @@ function on_ios11() { let elems = document.querySelectorAll('.commitchange-donate') for(let i = 0; i < elems.length; ++i) { - let elem = elems[i] + let elem:any = elems[i] let source = baseSource let optionsButton = commitchange.getParamsFromButton(elem) @@ -145,6 +148,8 @@ function on_ios11() { iframe.setAttribute('class', 'commitchange-iframe-embedded') commitchange.iframes.push(iframe) } else { + let overlay = commitchange.overlay() + let iframe // Show the CommitChange-branded button if it's not set to custom. if(!elem.hasAttribute('data-custom') && !elem.hasAttribute('data-custom-button')) { let btn_iframe = document.createElement('iframe') @@ -160,8 +165,7 @@ function on_ios11() { // Create the iframe overlay for this button let modal = document.createElement('div') modal.className = 'commitchange-modal' - let overlay = commitchange.overlay() - let iframe + if(commitchange.modalIframe) { iframe = commitchange.modalIframe } else { @@ -211,8 +215,8 @@ function on_ios11() { commitchange.loadStylesheet() commitchange.appendMarkup() }) - } else if(window.jQuery) { - window.jQuery(document).ready(() => { + } else if(windowAsAny.jQuery) { + windowAsAny.jQuery(document).ready(() => { commitchange.loadStylesheet() commitchange.appendMarkup() }) diff --git a/app/javascript/legacy/bank_accounts/confirm/index.es6 b/app/javascript/legacy/bank_accounts/confirm/index.es6 new file mode 100644 index 00000000..39365009 --- /dev/null +++ b/app/javascript/legacy/bank_accounts/confirm/index.es6 @@ -0,0 +1,75 @@ +// License: LGPL-3.0-or-later +const h = require('virtual-dom/h') +const request = require('../../common/super-agent-frp') +const view = require('vvvview') +const flyd = require('flyd') +const flatMap = require('flyd/module/flatmap') +const thunk = require('vdom-thunk') +const format = require('../../common/format') +const Im = require('immutable') +const fromJS = Im.fromJS +const Map = Im.Map + +var npURL = '/nonprofits/' + app.nonprofit_id + + +const root = state => { + var headerContent = '' + if(state.get('loading')) { + headerContent = [h('i.fa.fa-spin.fa-gear'), ' Confirming Bank Account...'] + } else if(!state.get('loading') && !state.get('pending_verification')) { + headerContent = [h('i.fa.fa-check'), ' Bank Account Confirmed!'] + } else { // not loading and unable to confirm + headerContent = ['Unable to confirm bank account.'] + } + + var confirmedMsg = !state.get('pending_verification') + ? h('p', [ + 'Your bank account connection has been confirmed with your email address. ', + h("br"), + h('a', {href: npURL + '/payouts'}, [h('i.fa.fa-return'), 'Return to your payouts dashboard']) + ]) + : '' + + return h('div', [ + h('h2', headerContent), + confirmedMsg, + thunk(accountInfo, state), + h('hr'), + h('p', [ + 'If any of this looks incorrect, please contact: ', + h('a', {href: 'mailto:support@commitchange.com'}, 'support@commitchange.com') + ]) + ]) +} + + +const accountInfo = state => + h('div.well', [ + h('p', ['Nonprofit: ', h('strong', state.getIn(['nonprofit', 'name'])), ]), + h('p', ['New bank account: ', h('strong', state.get('name')), ]), + h('p', ['User who made the change: ', h('strong', state.get('email')), ]), + h('p', ['Date and time of update: ', h('strong', format.date.toSimple(state.get('created_at'))), ]), + ]) + + +var state = fromJS(app.bankAccount).set('loading', true) + +var confirmView = view(root, document.querySelector('.js-view-confirm'), state) + + +if(app.bankAccount.pending_verification) { + var $confirmResponse = request.post(npURL + '/bank_account/confirm') + .send({token: utils.get_param('t')}) + .perform() + + var $state = flyd.scan( + (state, resp) => state.set('loading', false).set('pending_verification', false) + , state + , $confirmResponse) + + flyd.map(confirmView, $state) +} else { + confirmView(state.set('loading', false).set('pending_verification', false)) +} + diff --git a/app/javascript/legacy/bank_accounts/confirm/page.js b/app/javascript/legacy/bank_accounts/confirm/page.js new file mode 100644 index 00000000..a3d1fc6e --- /dev/null +++ b/app/javascript/legacy/bank_accounts/confirm/page.js @@ -0,0 +1,2 @@ +// License: LGPL-3.0-or-later +require('./index.es6') diff --git a/app/javascript/legacy/bank_accounts/create.es6 b/app/javascript/legacy/bank_accounts/create.es6 new file mode 100755 index 00000000..c86d1054 --- /dev/null +++ b/app/javascript/legacy/bank_accounts/create.es6 @@ -0,0 +1,75 @@ +// License: LGPL-3.0-or-later +var request = require('../common/super-agent-promise') +var format_err = require('../common/format_response_error') + +module.exports = create_bank_account + +function create_bank_account(form_data, el) { + return new Promise((resolve, reject) =>{ + appl.def('new_bank_account', {loading: true, error: ''}) + return confirm_auth(form_data) + .then(tokenize_with_stripe) + .then(create_record) + .then(complete) + .catch(display_err) + }) +} + + +// Post to confirm user's password +function confirm_auth(form_data) { + + return request.post('/users/confirm_auth').send({password: form_data.user_password}) + .perform() + .then((resp) =>{ return {token: resp.body.token, form: form_data}}) + + +} + + +// Post to stripe to get back a stripe_bank_account_token +function tokenize_with_stripe(data) { + return new Promise(function(resolve, reject) { + Stripe.bankAccount.createToken(data.form, function(status, resp) { + data.stripe_resp = resp + if(resp.error) reject(resp.error.message) + else resolve(data) + }) + }) +} + + +// 'data' must have a stripe response as '.stripe_resp' and a user password confirmation token as '.token +function create_record(data) { + return request.post('/nonprofits/' + app.nonprofit_id + '/bank_account') + .send({ + pw_token: data.token, + bank_account: { + stripe_bank_account_token: data.stripe_resp.id, + stripe_bank_account_id: data.stripe_resp.bank_account.id, + name: data.stripe_resp.bank_account.bank_name + ' *' + data.stripe_resp.bank_account.last4, + email: app.user.email + } + }) + .perform() +} + +function complete() { + appl.is_loading() + appl.reload() +} + +function display_err(resp) { + + var error_message = null; + + if (typeof resp == 'string') + error_message = resp + else + error_message = format_err(resp) + + appl.def('new_bank_account', {error: error_message, loading: false}) +} + + + diff --git a/app/javascript/legacy/bank_accounts/resend_confirmation_email.js b/app/javascript/legacy/bank_accounts/resend_confirmation_email.js new file mode 100644 index 00000000..5eb6843a --- /dev/null +++ b/app/javascript/legacy/bank_accounts/resend_confirmation_email.js @@ -0,0 +1,11 @@ +// License: LGPL-3.0-or-later +var request = require('../common/super-agent-frp') + +var a = document.querySelector(".js-event-resendBankConfirmEmail") + +if(a) a.addEventListener('click', resendBankConfirmation) + +function resendBankConfirmation() { + request.post('/nonprofits/' + app.nonprofit_id + '/bank_account/resend_confirmation').perform() + appl.open_modal('bankConfirmResendModal') +} diff --git a/app/javascript/legacy/campaigns/index/page.js b/app/javascript/legacy/campaigns/index/page.js new file mode 100644 index 00000000..2d0166dd --- /dev/null +++ b/app/javascript/legacy/campaigns/index/page.js @@ -0,0 +1,4 @@ +// License: LGPL-3.0-or-later +if(app.user) + require('../new/wizard') + diff --git a/app/javascript/legacy/campaigns/new/peer_to_peer_wizard.js b/app/javascript/legacy/campaigns/new/peer_to_peer_wizard.js new file mode 100644 index 00000000..c839d09c --- /dev/null +++ b/app/javascript/legacy/campaigns/new/peer_to_peer_wizard.js @@ -0,0 +1,43 @@ +// License: LGPL-3.0-or-later + +//This is used for federated p2p campaigns +require('../../components/wizard') +var format_err = require('../../common/format_response_error') + +appl.def('advance_p2p_campaign_name_step', function(form_obj) { + var name = form_obj['campaign[name]'] + appl.def('new_p2p_campaign', form_obj) + appl.wizard.advance('new_p2p_campaign_wiz') +}) + +// Post a new campaign. +appl.def('create_p2p_campaign', function(el) { + var form_data = utils.toFormData(appl.prev_elem(el)) + form_data = utils.mergeFormData(form_data, appl.new_p2p_campaign) + appl.def('new_p2p_campaign_wiz.loading', true) + + post_p2p_campaign(form_data) + .then(function(req) { + appl.notify("Redirecting to your campaign...") + appl.redirect(JSON.parse(req.response).url) + }) + .catch(function(req) { + appl.def('new_p2p_campaign_wiz.loading', false) + appl.def('new_p2p_campaign_wiz.error', req.responseText) + }) +}) + +// Using the bare-bones XMLHttpRequest API so we can post form data and upload the image +function post_p2p_campaign(form_data) { + return new Promise(function(resolve, reject) { + var req = new XMLHttpRequest() + req.open("POST", '/nonprofits/' + app.nonprofit_id + '/campaigns') + req.setRequestHeader('X-CSRF-Token', window._csrf) + console.log(form_data) + req.send(form_data) + req.onload = function(ev) { + if(req.status === 200) resolve(req) + else reject(req) + } + }) +} diff --git a/app/javascript/legacy/campaigns/new/wizard.js b/app/javascript/legacy/campaigns/new/wizard.js new file mode 100644 index 00000000..ab107da1 --- /dev/null +++ b/app/javascript/legacy/campaigns/new/wizard.js @@ -0,0 +1,57 @@ +// License: LGPL-3.0-or-later +require('../../common/pikaday-timepicker') +require('../../components/wizard') +require('../../common/image_uploader') +var checkName = require('../../common/ajax/check_campaign_or_event_name') +var format_err = require('../../common/format_response_error') + + +appl.def('advance_campaign_name_step', function(form_obj) { + var name = form_obj['campaign[name]'] + checkName(name, 'campaign', function(){ + appl.def('new_campaign', form_obj) + appl.wizard.advance('new_campaign_wiz') + }) +}) + +// Post a new campaign. +appl.def('create_campaign', function(el) { + var form_data = utils.toFormData(appl.prev_elem(el)) + form_data = utils.mergeFormData(form_data, appl.new_campaign) + appl.def('new_campaign_wiz.loading', true) + +// TODO: for p2p capmaigns, merge with preset campaing params + + post_campaign(form_data) + .then(function(req) { + appl.notify("Redirecting to your campaign...") + appl.redirect(JSON.parse(req.response).url) + }) + .catch(function(req) { + appl.def('new_campaign_wiz.loading', false) + appl.def('new_campaign_wiz.error', req.responseText) + }) +}) + + +var Pikaday = require('pikaday') +var moment = require('moment') +new Pikaday({ + field: document.querySelector('.js-date-picker'), + format: 'M/D/YYYY', + minDate: moment().toDate() +}) + +// Using the bare-bones XMLHttpRequest API so we can post form data and upload the image +function post_campaign(form_data) { + return new Promise(function(resolve, reject) { + var req = new XMLHttpRequest() + req.open("POST", '/nonprofits/' + app.nonprofit_id + '/campaigns') + req.setRequestHeader('X-CSRF-Token', window._csrf) + req.send(form_data) + req.onload = function(ev) { + if(req.status === 200) resolve(req) + else reject(req) + } + }) +} diff --git a/app/javascript/legacy/campaigns/peer_to_peer/page.js b/app/javascript/legacy/campaigns/peer_to_peer/page.js new file mode 100644 index 00000000..c9730bcf --- /dev/null +++ b/app/javascript/legacy/campaigns/peer_to_peer/page.js @@ -0,0 +1,129 @@ +// License: LGPL-3.0-or-later +require('../new/peer_to_peer_wizard') +require('../new/wizard.js') +require('../../common/image_uploader') + +var request = require("../../common/client") + +appl.def('undelete_p2p', function (url){ + appl.def('loading', true) + request.put(url + '/soft_delete', {delete: false}).end(function(err, resp) { + if (err) { + appl.def('loading', false) + } + else{ + window.location = url + } + + }) +}) + +// setting up some default values +appl.def('is_signing_up', true) + .def('selected_result_index', -1) + + +appl.def('search_nonprofits', function(value){ + // keyCode 13 is the return key. + // this conditional just clears the dropdown + if(event.keyCode === 13) { + appl.def('search_results', []) + return + } + // when the user starts typing, + // it sets the selected_results key to false + appl.def('selected_result', false) + + // if the the input is empty, it clears the dropdown + if (!value) { + appl.def('search_results', []) + return + } + + // logic for controlling the dropdown options with up + // and down arrows + if (returnUpOrDownArrow() && appl.search_results && appl.search_results.length) { + event.preventDefault() + setIndexWithArrows(returnUpOrDownArrow()) + return + } + + // if the input is not an up or down arrow or an empty string + // or a return key, then it searches for nonprofits + utils.delay(300, function(){ajax_nonprofit_search(value)}) +}) + + +function ajax_nonprofit_search(value){ + request.get('/nonprofits/search?npo_name=' + value).end(function(err, resp){ + if(!resp.body) { + appl.def('search_results', []) + appl.notify("Sorry, we couldn't find any nonprofits containing the word '" + value + "'") + } else { + appl.def('selected_result_index', -1) + appl.def('search_results', resp.body) + } + }) +} + + +function returnUpOrDownArrow() { + var keyCode = event.keyCode + if(keyCode === 38) + return 'up' + if(keyCode === 40) + return 'down' +} + + +function setIndexWithArrows(dir) { + if(dir === 'down') { + var search_length = appl.search_results.length -1 + appl.def('selected_result_index', appl.selected_result_index === search_length + ? search_length + : appl.selected_result_index += 1) + } else { + appl.def('selected_result_index', appl.selected_result_index === 0 + ? 0 + : appl.selected_result_index -= 1) + } +} + +appl.def('select_result', { + with_arrows: function(i, node) { + addSelectedClass(appl.prev_elem(node)) + var selected = appl.search_results[appl.selected_result_index] + app.nonprofit_id = selected.id + appl.def('selected_result', selected) + utils.change_url_param('npo_id', selected.id, '/peer-to-peer') + }, + with_click: function(i, node) { + appl.def('selected_result_index', i) + addSelectedClass(appl.prev_elem(node)) + var selected = appl.search_results[i] + app.nonprofit_id = selected.id + appl.def('selected_result', selected) + appl.def('search_results', []) + utils.change_url_param('npo_id', selected.id, '/peer-to-peer') + } +}) + + +function addSelectedClass(node) { + if(!node || !node.parentElement) return + var siblings = node.parentElement.querySelectorAll('li') + var len = siblings.length + while(len--){siblings[len].className=''} + node.className = 'is-selected' +} + +// this is for clearing the dropdown +var main = document.querySelector('main') + +main.onclick = function(ev) { + var node = ev.target.nodeName + if(node === 'INPUT' || node === 'BUTTON') { + return + } + appl.def('search_results', []) +} diff --git a/app/javascript/legacy/campaigns/show/admin.js b/app/javascript/legacy/campaigns/show/admin.js new file mode 100644 index 00000000..c4703b95 --- /dev/null +++ b/app/javascript/legacy/campaigns/show/admin.js @@ -0,0 +1,113 @@ +// License: LGPL-3.0-or-later +require('../../common/pikaday-timepicker') +require('../../common/restful_resource') +const request = require('../../common/client') +const formatErr = require('../../common/format_response_error') +require('../../common/image_uploader') +require('./tour') +const dupeIt = require('../../components/duplicate_fundraiser') + +dupeIt(`/nonprofits/${app.nonprofit_id}/campaigns`, app.campaign_id) + +var url = '/nonprofits/' + app.nonprofit_id + '/campaigns/' + app.campaign_id +var create_supporter = require('../../nonprofits/supporters/create') +var create_offline_donation = require('../../donations/create_offline') + +require('../../components/ajax/toggle_soft_delete')(url, 'campaign') + +// Initialize the froala wysiwyg +var editable = require('../../common/editable') +if (app.is_parent_campaign) { + editable($('#js-campaignBody'), { + sticky: true, + placeholder: "Add your campaign's story here. We strongly recommend that this section is filled out with at least 250 words. It will be saved automatically as you type. You can add images, videos and custom HTML too." + }) +} + +editable($('#js-customReceipt'), { + button: ["bold", "italic", "formatBlock", "align", "createLink", + "insertImage", "insertUnorderedList", "insertOrderedList", + "undo", "redo", "insert_donate_button", "html"] + , placeholder: "Add optional message here. It will be saved automatically as you type." +}) + + + +var path = '/nonprofits/' + app.nonprofit_id + '/campaigns/' + app.campaign_id + + +appl.def('remove_banner_image', function() { + var url = '/nonprofits/' + app.nonprofit_id + '/campaigns/' + app.campaign_id + var notification = 'Removing banner image...' + var payload = {remove_banner_image : true} + appl.remove_image(url, 'campaign', notification, payload) +}) + +appl.def('remove_background_image', function() { + var url = '/nonprofits/' + app.nonprofit_id + '/campaigns/' + app.campaign_id + var notification = 'Removing background image...' + var payload = {remove_background_image : true} + appl.remove_image(url, 'campaign', notification, payload) +}) + +appl.def('count_story_words', function() { + var wysiwyg = document.querySelector(".editable") + appl.def('has_story', wysiwyg.textContent.split(' ').length > 60) +}) + +appl.def('highlight_body', function(){ + appl.def('body_is_highlighted', true) + appl.close_modal() +}) + +appl.count_story_words(document.querySelector('.campaign-body')) + +appl.def('track_launch', function() { + window.location.reload() +}) + +appl.def('create_offline_donation', function(data, el) { + create_supporter({supporter: data.supporter}, createSupporterUI) + .then(function(resp) { + data.supporter_id = resp.body.id + delete data.supporter + return create_offline_donation(data, createDonationUI) + }).then(function(el){ + appl.ajax_metrics.index() + appl.prev_elem(el).reset() + }) +}) + + +var createSupporterUI = { + start: function() { + appl.is_loading() + }, + success: function() { + return this + }, + fail: function(msg) { + appl.def('error', formatErr(msg)) + appl.not_loading() + } +} + +var createDonationUI = { + start: function() { }, + success: function(resp) { + appl.not_loading() + appl.close_modal() + appl.notify('Campaign Donation Saved!') + }, + fail: function(msg){ + appl.def('error', formatErr(msg)) + appl.not_loading() + } +} + +if(app.vimeo_id) { + request.get('http://vimeo.com/api/v2/video/' + app.vimeo_id + '.json') + .end(function(err, resp){ + appl.def('vimeo_image_url', "background-image:url('" + resp.body[0].thumbnail_small + "')") + }) +} diff --git a/app/javascript/legacy/campaigns/show/choose-gift-options-modal.js b/app/javascript/legacy/campaigns/show/choose-gift-options-modal.js new file mode 100644 index 00000000..2b42675a --- /dev/null +++ b/app/javascript/legacy/campaigns/show/choose-gift-options-modal.js @@ -0,0 +1,44 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const flyd = require('flyd') +const R = require('ramda') + +const soldOut = require('./is-sold-out') +const giftButton = require('./gift-option-button') + +const giftOption = g => h('option', { props: {value: g.id} }, g.name) + +const setDisplayGift = (state, gifts) => ev => { + var id = Number(ev.target.value) + state.selectedModalGift$(R.find(R.propEq('id', id))(gifts)) +} + +const chooseGift = (state, gifts) => + h('div.pastelBox--grey.u-padding--10', [ + h('select.u-margin--0', {on: {change: setDisplayGift(state, gifts)}} + , R.concat([h('option', 'Choose a gift option')], R.map(giftOption, gifts))) + , h('div.sideGifts', + state.selectedModalGift$() && state.selectedModalGift$().id + ? [ + h('p.u-marginTop--10', state.selectedModalGift$().description || '') + , giftButton(state.giftOptions, state.selectedModalGift$()) + ] + : '' + ) + ]) + +const regularContribution = state => { + if (app.campaign.hide_custom_amounts) return '' + return h('div.u-marginTop--15.centered', [ + h('a', {on: {click: state.clickRegularContribution$}}, 'Contribute with no gift option') + ]) +} + +module.exports = state => { + var gifts = R.filter(g => !soldOut(g), state.giftOptions.giftOptions$() || []) + return h('div.u-padding--15', [ + chooseGift(state, gifts) + , regularContribution(state) + ]) +} + diff --git a/app/javascript/legacy/campaigns/show/gift-option-button.js b/app/javascript/legacy/campaigns/show/gift-option-button.js new file mode 100644 index 00000000..b4c67a9d --- /dev/null +++ b/app/javascript/legacy/campaigns/show/gift-option-button.js @@ -0,0 +1,57 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const branding = require('../../components/nonprofit-branding') +const format = require('../../common/format') +const soldOut = require('./is-sold-out') + +// function prepareForIOS11() +// { +// bad_elements = $('.ff-modalBackdrop') +// for(var i = 0; i < bad_elements.length; i++) +// { +// bad_elements[i].classList.add('ios-force-absolute-positioning') +// } +// +// +// $('body').scrollTop(195) // so incredibly hacky +// } + + +module.exports = (state, gift) => { + if(state.timeRemaining$() <= 0) return '' // dont show gift options button if the campaign has ended + return h('table', { + class: {'u-hide': !gift.amount_one_time && !gift.amount_recurring} + }, [ + h('tr', [ + gift.amount_one_time + ? h('td', [ + h('button.button--small.button--gift', { + on: {click: ev => { + + + state.clickOption$([gift, gift.amount_one_time, 'one-time'])} + } + + , style: {background: branding.dark} + , props: {title: `Contribute towards ${gift.name}`} + , class: {disabled: soldOut(gift)} + }, [ h('span.dollar', '$ ') , format.centsToDollars(gift.amount_one_time), h('br'), h('small', 'One-time') ]) + ]) + : '' // no one-time amount + , gift.amount_recurring && gift.amount_one_time ? h('td.orWithLine') : '' // whether to show the cool OR graphic between buttons + , gift.amount_recurring + ? h('td', [ + h('button.button--small.button--gift', { + on: {click: ev => { + + state.clickOption$([gift, gift.amount_recurring, 'recurring'])} + } + , style: {background: branding.dark} + , props: {title: `Contribute monthly towards ${gift.name}`} + , class: {disabled: soldOut(gift)} + }, [h('span.dollar', '$ '), format.centsToDollars(gift.amount_recurring), h('br'), h('small', 'Monthly') ]) + ]) + : '' // no recurring amount + ]) + ]) +} diff --git a/app/javascript/legacy/campaigns/show/gift-option-list.js b/app/javascript/legacy/campaigns/show/gift-option-list.js new file mode 100644 index 00000000..a2ef43bb --- /dev/null +++ b/app/javascript/legacy/campaigns/show/gift-option-list.js @@ -0,0 +1,81 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const flyd = require('flyd') +const R = require('ramda') +const request = require('../../common/request') +const format = require('../../common/format') +const branding = require('../../components/nonprofit-branding') +flyd.mergeAll = require('flyd/module/mergeall') + +const quantityLeft = require('./gift-option-quantity-left') +const giftButton = require('./gift-option-button') + +// Pass in a stream that has a value when the gift options need to be refreshed, so we know when to refresh em! +function init(giftsNeedRefresh$, parentState) { + var state = { + timeRemaining$: parentState.timeRemaining$ + , clickOption$: flyd.stream() + , openEditGiftModal$: flyd.stream() + } + + // XXX some legacy viewscript mixed in here + flyd.map(gift => { + appl.open_modal('giftOptionFormModal') + appl.def('gift_options', {current: gift, is_updating: true}) + appl.def('gift_option_action', 'Edit') + }, state.openEditGiftModal$) + + const pageloadGifts$ = index() + const refreshedGifts$ = flyd.flatMap(index, giftsNeedRefresh$) + state.giftOptions$ = flyd.mergeAll([ + pageloadGifts$ + , refreshedGifts$ + , flyd.stream([]) // default before ajax loads + ]) + return state +} + +function index() { + const path = `/nonprofits/${app.nonprofit_id}/campaigns/${app.campaign_id}/campaign_gift_options` + return flyd.map( + req => req.body.data + , request({path, method: 'get'}).load + ) +} + +function view(state) { + return h('aside.sideGifts.u-marginBottom--15', { + class: {'u-hide': !state.giftOptions$().length} + }, R.map(giftBox(state), state.giftOptions$()) + ) +} + +const giftBox = state => gift => { + return h('section.u-relative', [ + h('div.sideGift.pastelBox--grey--dark', [ + h('h5.u-marginTop--0', gift.name) + , totalContributions(gift) + , quantityLeft(gift) + , h('p.u-marginBottom--15', gift.description) + , h('div', [ giftButton(state, gift) ]) + ]) + , (app.current_campaign_editor && app.is_parent_campaign) // Show edit button only if the current user is a parent campaign editor + ? h('button.button--tiny.absolute.edit.hasShadow', { + on: {click: ev => state.openEditGiftModal$(gift)} + }, [ + h('i.fa.fa-pencil') + , ' Edit Gift' + ]) + : '' // do not show gift edit button + ]) +} + +const totalContributions = gift => { + if(gift.hide_contributions) return '' + return h('p', [ + h('i.fa.fa-star', { style: { color: branding.base} }) + , ` ${format.numberWithCommas(gift.total_gifts)} Contribution${gift.total_gifts === 1 ? '' : 's'}` + ]) +} + +module.exports = {view, init} diff --git a/app/javascript/legacy/campaigns/show/gift-option-quantity-left.js b/app/javascript/legacy/campaigns/show/gift-option-quantity-left.js new file mode 100644 index 00000000..e109f3ae --- /dev/null +++ b/app/javascript/legacy/campaigns/show/gift-option-quantity-left.js @@ -0,0 +1,18 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const soldOut = require('./is-sold-out') + +module.exports = gift => { + if(gift.hide_contributions || !gift.quantity) return '' + + if(soldOut(gift)) { + return h('p', [ + h('small.strong.highlight--white--small', 'SOLD OUT') + ]) + } else { + return h('p', [ + h('small.strong.highlight--white--small', [ `${gift.quantity - gift.total_gifts} Left` ]) + ]) + } +} + diff --git a/app/javascript/legacy/campaigns/show/is-sold-out.js b/app/javascript/legacy/campaigns/show/is-sold-out.js new file mode 100644 index 00000000..e013f44b --- /dev/null +++ b/app/javascript/legacy/campaigns/show/is-sold-out.js @@ -0,0 +1,3 @@ +// License: LGPL-3.0-or-later +module.exports = g => g.quantity && (g.quantity - g.total_gifts <= 0) + diff --git a/app/javascript/legacy/campaigns/show/metrics-and-contribute-box.js b/app/javascript/legacy/campaigns/show/metrics-and-contribute-box.js new file mode 100644 index 00000000..ac031fef --- /dev/null +++ b/app/javascript/legacy/campaigns/show/metrics-and-contribute-box.js @@ -0,0 +1,105 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const flyd = require('flyd') +const h = require('snabbdom/h') +const format = require('../../common/format') +const branding = require('../../components/nonprofit-branding') + +// This is the box currently at the top right that shows some big metrics for +// the campaign, a big Contribute button (if enabled to show), days remaining +// (and a "campaign is done" message if no days remaining) + +function init(parentState) { + var state = { + clickContribute$: flyd.stream() + , timeRemaining$: parentState.timeRemaining$ + , metrics$: parentState.metrics$ + , loading$: parentState.loadingMetrics$ + } + + return state +} + + +function view(state) { + return h('div.pastelBox--grey--dark.u-relative.u-marginBottom--15.u-padding--15', [ + metrics(state) + , endedMessage(state) + , progressBar(state) + , contributeButton(state) + ]) +} + +const metrics = state => { + return h('div.campaignMetrics', [ + totalSupporters(state) + , totalRaised(state) + , daysLeft(state) + ]) +} + +const totalSupporters = state => { + if(!app.campaign.show_total_count) return '' + return h('div', [ + h('h4', [ + state.loading$() ? h('i.fa.fa-spin.fa-spinner') : format.numberWithCommas(state.metrics$().supporters_count) + ]) + , h('p', 'supporters') + ]) +} + +const totalRaised = state => { + if(!app.campaign.show_total_raised) return '' + return h('div', [ + h('h4', [ + state.loading$() ? h('i.fa.fa-spin.fa-spinner') : '$' + format.centsToDollars(state.metrics$().total_raised, {noCents: true}) + ]) + , h('p', [ + 'raised' + , app.campaign.hide_goal + ? '' + : ' of $' + format.centsToDollars(app.campaign.goal_amount) + ' goal' + ]) + ]) +} + +const daysLeft = state => { + if(!state.timeRemaining$()) return '' + return h('div', [ + h('h4', state.timeRemaining$()) + , h('p', 'remaining') + ]) +} + +const endedMessage = state => { + if(state.timeRemaining$()) return '' + return h('p', [ + `This campaign has ended, but you can still contribute by clicking the button below.` + ]) +} + +const progressBar = state => { + if(app.campaign.hide_thermometer) return '' + return h('div.progressBar--medium.u-marginBottom--15', [ + h('div.progressBar--medium-fill', { + style: { + width: R.clamp(1,100, format.percent( + state.metrics$().goal_amount + , state.metrics$().total_raised + ) + '%') + , 'background-color': branding.light + , transition: 'width 1s' + } + }) + ]) +} + +const contributeButton = state => { + return h('a.js-contributeButton.button--jumbo.u-width--full', { + style: {'background-color': branding.base} + , on: {click: state.clickContribute$} + }, [ 'Contribute' ]) +} + +module.exports = { init, view } + diff --git a/app/javascript/legacy/campaigns/show/page.js b/app/javascript/legacy/campaigns/show/page.js new file mode 100755 index 00000000..e965c5ed --- /dev/null +++ b/app/javascript/legacy/campaigns/show/page.js @@ -0,0 +1,169 @@ +// License: LGPL-3.0-or-later +const flyd = require('flyd') +const h = require('snabbdom/h') +const R = require('ramda') +const donateWiz = require('../../nonprofits/donate/wizard') +const render = require('ff-core/render') +const snabbdom = require('snabbdom') +const modal = require('ff-core/modal') +flyd.mergeAll = require('flyd/module/mergeall') +flyd.scanMerge = require('flyd/module/scanmerge') +const format = require('../../common/format') +const giftOptions = require('./gift-option-list') +const chooseGiftOptionsModal = require('./choose-gift-options-modal') +const metricsAndContributeBox = require('./metrics-and-contribute-box') +const timeRemaining = require('../../common/time-remaining') +const request = require('../../common/request') + +const activities = require('../../components/public-activities') + + +// Viewscript legacy side effect stuff +require('../../components/branded_fundraising') +require('../../common/on-change-sanitize-slug') +require('../../common/fundraiser_metrics') +require('../../components/fundraising/add_header_image') +require('../../common/restful_resource') +require('../../gift_options/index') +const on_ios11 = require('../../common/on-ios11') +const noScroll = require('no-scroll') +appl.ajax_gift_options.index() + + + +// Campaign editor only functionality +if(app.current_campaign_editor) { + require('./admin') + appl.def('current_campaign_editor', true) + require('../../gift_options/admin') + var create_info_card = require('../../supporters/info-card.es6') +} + +// Initialize the state for the top-level campaign component +// This includes the metrics, contribute button, gift options listing, and the donate wizard (most of right sidebar) +// Later can include the other viewscript pieces +function init() { + var state = { + timeRemaining$: timeRemaining(app.end_date_time, app.timezone), + } + + console.error(window.navigator.userAgent) + state.giftOptions = giftOptions.init(flyd.stream(), state) + + const metricsResp$ = flyd.map(R.prop('body'), request({ + method: 'get' + , path: `/nonprofits/${app.nonprofit_id}/campaigns/${app.campaign.id}/metrics` + }).load) + state.loadingMetrics$ = flyd.mergeAll([ + flyd.map(_ => false, metricsResp$) + , flyd.stream(true) + ]) + state.metrics$ = flyd.merge( + flyd.stream({goal_amount: 0, total_raised: 0, supporters_count: 0}) + , metricsResp$ + ) + state.metrics = metricsAndContributeBox.init(state) + + state.activities = activities.init('campaign', `/nonprofits/${app.nonprofit_id}/campaigns/${app.campaign_id}/activities`) + + + const contributeModalType$ = R.compose( + flyd.map(_ => + state.timeRemaining$() && state.giftOptions.giftOptions$().length + ? 'gifts' : 'regular') + )(state.metrics.clickContribute$) + + const clickContributeGifts$ = flyd.filter(x => x === 'gifts', contributeModalType$) + + const clickContributeRegular$ = flyd.filter(x => x === 'regular', contributeModalType$) + + state.clickRegularContribution$ = flyd.stream() + + const startWiz$ = flyd.mergeAll([ + state.giftOptions.clickOption$ + , clickContributeRegular$ + , state.clickRegularContribution$ + ]) + + state.selectedModalGift$ = flyd.stream({}) + + state.modalID$ = flyd.merge( + flyd.map(R.always('chooseGiftOptionsModal'), clickContributeGifts$) + , flyd.map(R.always('donationModal'), startWiz$)) + + flyd.on((id) => { + if (on_ios11() && id === null) { + noScroll.off() + } + }, state.modalID$) + + flyd.on((id) => { + if (on_ios11() && id !== null) { + noScroll.on() + } + }, state.modalID$) + + // Stream of which gift option you have selected + const giftOption$ = flyd.map(setGiftParams, state.giftOptions.clickOption$) + const donateParam$ = flyd.scanMerge([ + [state.metrics.clickContribute$, resetDonateForm] + , [giftOption$, setGiftOption] + ], {campaign_id: app.campaign.id} ) + + state.donateWiz = donateWiz.init(donateParam$) + + return state +} + +const resetDonateForm = (params, _) => R.merge(params, { + single_amount: undefined +, gift_option: undefined +, type: undefined +}) + +const setGiftOption = (params, gift) => R.merge(params, { + single_amount: gift.amount / 100 +, gift_option: gift +, type: gift.type +}) + + +// Set the donate wizard parameters using data from a gift option +const setGiftParams = (triple) => { + var [gift, amount, type] = triple + return { amount: amount, type: type , id: gift.id, name: gift.name, to_ship: gift.to_ship} +} + +function view(state) { + return h('div', [ + metricsAndContributeBox.view(state.metrics) + , giftOptions.view(state.giftOptions) + , activities.view(state.activities) + , h('div.donationModal', [ + modal({ + thisID: 'donationModal' + , id$: state.modalID$ + , body: donateWiz.view(state.donateWiz) + // , notCloseable: state.donateWiz.paymentStep.cardForm.loading$() + }) + , modal({ + thisID: 'chooseGiftOptionsModal' + , title: 'Contribute' + , id$: state.modalID$ + , body: chooseGiftOptionsModal(state) + }) + ]) + ]) +} + +// -- Render to the page + +const patch = snabbdom.init([ + require('snabbdom/modules/eventlisteners') +, require('snabbdom/modules/class') +, require('snabbdom/modules/props') +, require('snabbdom/modules/style') +]) + +render({state: init(), view, patch, container: document.querySelector('.ff-sidebar')}) + diff --git a/app/javascript/legacy/campaigns/show/tour.js b/app/javascript/legacy/campaigns/show/tour.js new file mode 100644 index 00000000..161a3174 --- /dev/null +++ b/app/javascript/legacy/campaigns/show/tour.js @@ -0,0 +1,35 @@ +// License: LGPL-3.0-or-later +require('../../common/vendor/bootstrap-tour-standalone') + +var tour_campaign = new Tour({ + steps: [ + { + orphan: true, + title: 'Welcome to your new campaign!', + content: "Click 'Next' to find out how you can flesh out your campaign before sharing it." + }, + { + title: 'Manage your campaign', + placement: 'bottom', + element: '.tour-admin', + content: "You can manage your campaign by clicking on these buttons at the top of the page." + }, + { + element: '.froala-box', + title: 'Write your story', + content: "Every successful campaign has a powerful story. Write and edit your story in the area to the left. You can add formatting by clicking the icons at the top of this box." + }, + { + orphan: true, + title: 'You’re on your way!', + content: "Once you’ve written your campaign story and added gift options, you can start sharing it with all your contacts. We’re excited for it to succeed!" + } + ] +}) + +if($.cookie('tour_campaign') === String(app.nonprofit_id)) { + $.removeCookie('tour_campaign', {path: '/'}) + tour_campaign.init() + tour_campaign.restart() +} + diff --git a/app/javascript/legacy/campaigns/supporters/index/index.es6 b/app/javascript/legacy/campaigns/supporters/index/index.es6 new file mode 100644 index 00000000..941ecbc8 --- /dev/null +++ b/app/javascript/legacy/campaigns/supporters/index/index.es6 @@ -0,0 +1,63 @@ +// License: LGPL-3.0-or-later +const request = require('../../../common/super-agent-frp') +const view = require('vvvview') +const flyd = require('flyd') +const scanMerge = require('flyd/module/scanmerge') +const flatMap = require('flyd/module/flatmap') +const Im = require('immutable') +const Map = Im.Map +const fromJS = Im.fromJS + +const list = require('./supporter-list.es6') + +var el = document.querySelector('.js-view-supporters') +var state = Map({loading: true}) +var listView = view(list.root, el, state) + +// Given a query object, return an ajax stream +const request_index = query => + request + .get(`/nonprofits/${app.nonprofit_id}/campaigns/${ENV.campaignID}/admin/supporters`) + .query(query) + .perform() + +var $searchResponses = flatMap(request_index, list.$streams.searches) + +const appendPage = (state, resp) => { + var oldSupporters = state.getIn(['supporters', 'data']) + var newData = fromJS(resp.body) + if(oldSupporters) newData = newData.set('data', oldSupporters.concat(newData.get('data'))) + return state + .set('supporters', newData) + .set('moreLoading', false) + .set('loading', false) +} + +const $showMorePages = flyd.scan( + count => count + 1 + , 1 + , list.$streams.showMore) + +const $newPages = flatMap( + page => request_index({page: page}) + , $showMorePages) + +const setResults = (state, resp) => + state.set('supporters', fromJS(resp.body)).set('loading', false) + +var $giftLevelResponses = + request.get(`/nonprofits/${app.nonprofit_id}/campaigns/${ENV.campaignID}/admin/campaign_gift_options`).perform() + +var $state = flyd.immediate(scanMerge([ + [list.$streams.searches, state => state.set('loading', true).set('isSearching', true)], + [list.$streams.showMore, state => state.set('moreLoading', true).set('page', state.get('page') + 1)], + [$newPages, appendPage], + [$searchResponses, setResults], + [$giftLevelResponses, (state, resp) => state.set('gift_levels', fromJS(resp.body))], +], state)) + +window.$state =$state +window.$giftLevelResponses= $giftLevelResponses + +flyd.map(listView, $state) + diff --git a/app/javascript/legacy/campaigns/supporters/index/meta.es6 b/app/javascript/legacy/campaigns/supporters/index/meta.es6 new file mode 100644 index 00000000..3d071607 --- /dev/null +++ b/app/javascript/legacy/campaigns/supporters/index/meta.es6 @@ -0,0 +1,27 @@ +// License: LGPL-3.0-or-later +// Table meta for the supporter listing under Campaigns +const h = require('virtual-dom/h') +const thunk = require('vdom-thunk') +const search = require('../../../components/tables/search.es6') + +const root = state => + h('div.container', [ + thunk(search.root, state), + h('a.table-meta-button.white', { + href: `/nonprofits/${app.nonprofit_id}/campaigns/${ENV.campaignID}/admin/donations.csv`, + target: '_blank', + }, [ h('i.fa.fa-file-text'), ' Export ' ]), + /* + h('a.table-meta-button.green', { + onclick: $.showEmailModal + }, [ h('i.fa.fa-envelope'), ' Email ' ]) + */ + ]) + +module.exports = { + root: root, + $streams: { + searches: search.$streams.searches + } +} + diff --git a/app/javascript/legacy/campaigns/supporters/index/metrics.es6 b/app/javascript/legacy/campaigns/supporters/index/metrics.es6 new file mode 100644 index 00000000..a476ba58 --- /dev/null +++ b/app/javascript/legacy/campaigns/supporters/index/metrics.es6 @@ -0,0 +1,35 @@ +// License: LGPL-3.0-or-later +const view = require('vvvview') +const h = require('virtual-dom/h') +const flyd = require('flyd') +const scanMerge = require('flyd/module/scanmerge') +const thunk = require('vdom-thunk') +const request = require('../../../common/super-agent-frp') +const format = require('../../../common/format') +const Im = require('immutable') +const Map = Im.Map +const fromJS = Im.fromJS + +const root = state => { + if(!state || !state.get('data')) return h('span') + return h('table.table--plaid', [ + h('thead', [ + h('tr', [h('th', 'Gift option'), h('th', 'Count'), h('th', 'One time'), h('th', 'Recurring')]), + ]), + h('tbody', state.get('data').map(gift => thunk(giftRow, gift)).toJS()) + ]) +} + +const giftRow = gift => { + + var name = gift.get('name') + name = !name || !name.length ? 'No Gift Option Chosen' : name + return h('tr', [ + h('td', h('strong', name)), + h('td', (gift.get('total_donations') || 0) + ''), + h('td', '$' + utils.cents_to_dollars(gift.get('total_one_time'))), + h('td', '$' + utils.cents_to_dollars(gift.get('total_recurring'))) + ]) +} + +module.exports = { root: root, $streams: $ } diff --git a/app/javascript/legacy/campaigns/supporters/index/page.js b/app/javascript/legacy/campaigns/supporters/index/page.js new file mode 100644 index 00000000..c1d016b8 --- /dev/null +++ b/app/javascript/legacy/campaigns/supporters/index/page.js @@ -0,0 +1,5 @@ +// License: LGPL-3.0-or-later +require('../../timeline') +require('../../totals') +require('./index.es6') + diff --git a/app/javascript/legacy/campaigns/supporters/index/supporter-list.es6 b/app/javascript/legacy/campaigns/supporters/index/supporter-list.es6 new file mode 100644 index 00000000..b5e64be4 --- /dev/null +++ b/app/javascript/legacy/campaigns/supporters/index/supporter-list.es6 @@ -0,0 +1,29 @@ +// License: LGPL-3.0-or-later +const h = require('virtual-dom/h') +const thunk = require('vdom-thunk') +const flyd = require('flyd') + + +const metrics = require('./metrics.es6') +const meta = require('./meta.es6') +const supporterTable = require('./supporter-table.es6') + +var $ = { + showMore: supporterTable.$streams.showMore, + searches: meta.$streams.searches, + showEmailModal: flyd.stream(), +} + +const root = state => + h('div', [ + h('section.table-meta', thunk(meta.root, state)), + h('section.metrics.container', thunk(metrics.root, state.get('gift_levels'))), + h('hr'), + h('section.container', thunk(supporterTable.root, state)), + h('hr'), + ]) + +// Table meta for the supporter listing under Campaigns + +module.exports = {root: root, $streams: $} + diff --git a/app/javascript/legacy/campaigns/supporters/index/supporter-table.es6 b/app/javascript/legacy/campaigns/supporters/index/supporter-table.es6 new file mode 100644 index 00000000..b5c053b1 --- /dev/null +++ b/app/javascript/legacy/campaigns/supporters/index/supporter-table.es6 @@ -0,0 +1,69 @@ +// License: LGPL-3.0-or-later +const thunk = require('vdom-thunk') +const h = require('virtual-dom/h') +const flyd = require('flyd') +const showMoreBtn = require('../../../components/show-more-button.es6') +const format = require("../../../common/format") +const date = format.date +const sql = format.sql + +const root = state => { + console.log({state}) + var supporters = state.get('supporters') + if(state.get('loading')) { + return h('p.noResults', ' Loading...') + } else if(supporters.get('data').count()) { + return h('div', [ + h('table.table--plaid', [ + h('thead', [ + h('th', 'Name'), + h('th', 'Total'), + h('th', 'Gift options'), + h('th', 'Latest gift'), + h('th', 'Campaign creator') + ]), + thunk(trs, supporters.get('data')), + ]), + thunk(showMoreBtn.root, state.get('moreLoading'), supporters.get('remaining')) + ]) + } else if (state.get('isSearching')) { + return h('p.noResults', ["Supporter not found."]) + } else { + return h('p.noResults', ["No donors yet. ", h('a', {href: './'}, 'Return to the campaign page.')]) + } +} + +const trs = supporters => + h('tbody', supporters.map(supp => thunk(supporterRow, supp)).toJS()) + +const supporterRow = supporter => + h('tr', [ + h('td', + h('a' + , { + href: `/nonprofits/${app.nonprofit_id}/supporters?sid=${supporter.get('id')}` + , target: '_blank' + } + , [supporter.get('name') + , h('br') + , h('small', supporter.get('email')) + ] + ) + ) + , h('td', '$' + utils.cents_to_dollars(supporter.get('total_raised'))), + h('td', supporter.get('campaign_gift_names').toJS().join(', ')), + h('td', supporter.get('latest_gift')), + h('td', {}, supporter.get('campaign_creator_emails').toJS().map( + function(i, index, array) { + return h('a', {href: `mailto:${i}`}, + i + ((i < (array.length - 1)) ? ", " : "")) + })), + ]) + +module.exports = { + root: root, + $streams: { + showMore: showMoreBtn.$streams.nextPageClicks, + } +} + diff --git a/app/javascript/legacy/campaigns/timeline.js b/app/javascript/legacy/campaigns/timeline.js new file mode 100644 index 00000000..5ff8c455 --- /dev/null +++ b/app/javascript/legacy/campaigns/timeline.js @@ -0,0 +1,93 @@ +// License: LGPL-3.0-or-later +const request = require('../common/client') +const R = require('ramda') +const Chart = require('chart.js') +const moment = require('moment') +const dateRange = require('../components/date-range') +const chartOptions = require('../components/chart-options') + +var url = `/nonprofits/${app.nonprofit_id}/campaigns/${ENV.campaignID}/timeline` + +function query() { + appl.def('loading_chart', true) + request.get(url) + .end(function(err, resp) { + appl.def('loading_chart', false) + var ctx = document.getElementById('js-timeline').getContext('2d') + new Chart(ctx, { + type: 'line' + , data: formatData(cumulative(resp.body)) + , options: chartOptions.dollars + }) + }) +} + +function cumulative(data) { + var moments = dateRange(R.head(data).date, R.last(data).date, 'days') + var dateStrings = R.map((m) => m.format('YYYY-MM-DD'), moments) + + var proto = { + offsite_cents: 0 + , onetime_cents: 0 + , recurring_cents: 0 + , total_cents: 0 + } + + var dateDictionary = R.reduce((a,b) => { + a[b] = R.merge(proto, {date: b}) + return a + }, {}, dateStrings) + + R.reduce((a, b) => { + a[b.date] = b + return a + }, dateDictionary, data) + + return R.tail(R.reduce((a, b) => { + var last = R.last(a) + b.offsite_cents += last.offsite_cents + b.onetime_cents += last.onetime_cents + b.recurring_cents += last.recurring_cents + b.total_cents += last.total_cents + return R.append(b, a) + }, [proto], R.values(dateDictionary))) +} + +function formatData(data) { + return { + labels: R.map((st) => moment(st).format('M/D/YYYY'), R.pluck('date', data)) + , datasets: [ + dataset('Total' + , 'total_cents' + , '190, 190, 190' + , data) + , dataset('One time' + , 'onetime_cents' + , '66, 179, 223' + , data) + , dataset('Recurring' + , 'recurring_cents' + , '240, 205, 108' + , data) + , dataset('Offsite' + , 'offsite_cents' + , '95, 184, 141' + , data) + ] + } +} + +function dataset(label, key, rgb, data) { + return { + label: label + , data: R.pluck(key, data) + , borderColor: `rgb(${rgb})` + , backgroundColor: `rgba(${rgb},0.2)` + , fill: false + , pointRadius: 0 + , pointHitRadius: 2 + } +} + +query() + diff --git a/app/javascript/legacy/campaigns/totals.js b/app/javascript/legacy/campaigns/totals.js new file mode 100644 index 00000000..0bcb1e02 --- /dev/null +++ b/app/javascript/legacy/campaigns/totals.js @@ -0,0 +1,15 @@ +// License: LGPL-3.0-or-later +const request = require('../common/request') +const flyd = require('flyd') +const R = require('ramda') +var path = `/nonprofits/${app.nonprofit_id}/campaigns/${ENV.campaignID}/totals` + +const resp$ = flyd.map(R.prop('body'), request({path, method: 'GET'}).load) + +appl.def('loading_totals', true) + +flyd.map(response => { + appl.def('loading_totals', false) + appl.def('campaign_totals', response) +}, resp$) + diff --git a/app/javascript/legacy/cards/create-frp.es6 b/app/javascript/legacy/cards/create-frp.es6 new file mode 100644 index 00000000..94ff4e9e --- /dev/null +++ b/app/javascript/legacy/cards/create-frp.es6 @@ -0,0 +1,11 @@ +// License: LGPL-3.0-or-later +const flyd = require('flyd') +const flyd_flatMap = require('flyd/module/flatmap') + +// Given an object of card data, return a stream of stripe tokenization responses +module.exports = obj => { + var $ = flyd.stream() + Stripe.card.createToken(obj, (status, resp) => $(resp)) + return $ +} + diff --git a/app/javascript/legacy/cards/create.js b/app/javascript/legacy/cards/create.js new file mode 100644 index 00000000..c8c22285 --- /dev/null +++ b/app/javascript/legacy/cards/create.js @@ -0,0 +1,129 @@ +// License: LGPL-3.0-or-later +// Include the cards/fields partial to use with this. +// Call appl.card_form.create(card_obj) to start the card creation process. +// Use the appl.card_form.on_fail callback to handle failures. +// Use the appl.card_form.on_complete callback to handle completion. +// This does not create any donations -- do the donation creation inside appl.card_form.on_complete. + +// Not namespacing card_form; only show one card form on the page at any time + +var request = require('../common/super-agent-promise') +var format_err = require('../common/format_response_error') + +module.exports = create_card + +// UI state defaults +appl.def('card_form', { + loading: false, + error: false, + status: '', + on_complete: function() {}, + on_fail: function() {}, + progress_width: '0%' // Width of the progress bar +}) + +// Define some status messages and progress bar widths for each step of the process +var statuses = { + before_tokenization: { + progress_width: '20%', + status: 'Double-checking your card...' + }, + before_create: { + progress_width: '75%', + status: 'Looks good! Sending the carrier pigeons...' + }, + on_complete: { + progress_width: '100%', + status: 'Processing payment...' + } +} + +// Tokenize with stripe, then save to our db. +// The first argument must be a holder object that has 'type' and 'id' keys. +// eg: {holder: {type: 'Nonprofit', id: 1}} +function create_card(holder, card_obj, options) { + options = options || {} + if(appl.card_form.loading) return + appl.def('card_form', { loading: true, error: false }) + appl.def('card_form', statuses.before_tokenization) + + // Delete the cvc key from card_obj if + // the value of cvc is a blank string. + // Otherwise, Stripe will return an error for + // incorrect security code. + if(card_obj.cvc === '') { + delete card_obj['cvc'] + } + + // First, tokenize the card with Stripe.js + return tokenize_with_stripe(card_obj) + .catch(display_stripe_err) + // Then, save a Card record in our db + .then(function(stripe_resp) { + appl.def('card_form', statuses.before_create) + return create_record(holder, stripe_resp, options) + }) + .then(function(resp) { + appl.def('card_form', statuses.on_complete) + return resp.body + }) + .catch(display_err) +} + +// Post to stripe to get back a stripe_card_token +function tokenize_with_stripe(card_obj) { + return new Promise(function(resolve, reject) { + Stripe.card.createToken(card_obj, function(status, resp) { + if(resp.error) reject(resp) + else resolve(resp) + }) + }) +} + +// Save a record of the card in our own db +function create_record(holder, stripe_resp, options={}) { + var output = {card: { + holder_type: holder.type, + holder_id: holder.id, + email: holder.email, + cardholders_name: stripe_resp.name, + name: stripe_resp.card.brand + ' *' + stripe_resp.card.last4, + stripe_card_token: stripe_resp.id, + stripe_card_id: stripe_resp.card.id + }} + if (options['event_id']) + { + output['event_id'] = options['event_id'] + } + + return request.post(options.path || '/cards') + .send(output) + .perform() +} + +// Set UI state to display an error in the card form. +function display_err(resp) { + if(resp && resp.body) { + appl.def('card_form', { + loading: false, + error: true, + status: format_err(resp), + progress_width: '0%' + }) + appl.def('loading', false) + } +} + +function display_stripe_err(resp) { + if(resp && resp.error) { + appl.def('card_form', { + loading: false, + error: true, + status: resp.error.message, + progress_width: '0%' + }) + appl.def('loading', false) + + throw new Error() + } +} diff --git a/app/javascript/legacy/common/ajax/check_campaign_or_event_name.js b/app/javascript/legacy/common/ajax/check_campaign_or_event_name.js new file mode 100644 index 00000000..517e9ee7 --- /dev/null +++ b/app/javascript/legacy/common/ajax/check_campaign_or_event_name.js @@ -0,0 +1,16 @@ +// License: LGPL-3.0-or-later +var R = require('ramda') +var request = require('../client') + +module.exports = function(name, event_or_campaign, callback) { + request.get(`/nonprofits/${app.nonprofit_id}/${event_or_campaign}s/name_and_id`) + .end(function(err, resp){ + var names = resp.body.map(x => x.name) + if(R.contains(name, names)) { + appl.notify(`Oops. It looks like you already have ${event_or_campaign === 'campaign' ? 'a' : 'an'} ${event_or_campaign} named '${name}'. Please choose a different name and try again.`) + return + } + callback() + }) +} + diff --git a/app/javascript/legacy/common/ajax/get_campaign_and_event_names_and_ids.js b/app/javascript/legacy/common/ajax/get_campaign_and_event_names_and_ids.js new file mode 100644 index 00000000..842d53ba --- /dev/null +++ b/app/javascript/legacy/common/ajax/get_campaign_and_event_names_and_ids.js @@ -0,0 +1,36 @@ +// License: LGPL-3.0-or-later +var request = require('../client') + +module.exports = function(npo_id) { + var campaignsPath = '/nonprofits/' + npo_id + '/campaigns/name_and_id' + var eventsPath = '/nonprofits/' + npo_id + '/events/name_and_id' + + request.get(campaignsPath).end(function(err, resp){ + var dataResponse = [] + + if (!err) { + resp.body.unshift(false) + dataResponse = resp.body.map((i) => { + if (i.isChildCampaign) + { + return {id: i.id, name: i.name + " - " + i.creator} + } + else + { + return {id: i.id, name: i.name} + } + }) + } + appl.def('campaigns.data', dataResponse) + }) + + request.get(eventsPath).end(function(err, resp){ + var dataResponse = [] + if(!err) { + resp.body.unshift(false) + dataResponse = resp.body + } + + appl.def('events.data', dataResponse) + }) +} diff --git a/app/javascript/legacy/common/application_view.js b/app/javascript/legacy/common/application_view.js new file mode 100644 index 00000000..a60cbdd9 --- /dev/null +++ b/app/javascript/legacy/common/application_view.js @@ -0,0 +1,393 @@ +// License: LGPL-3.0-or-later +var confirmation = require('./confirmation') +var notification = require('./notification') +var request = require("superagent") +var moment = require('moment-timezone') +var client = require('./client') +var appl = require('view-script') +const on_ios11 = require('./on-ios11') +const noScroll = require('no-scroll') + +module.exports = appl + +// A couple short convenience functions for disabling/enabling the global +// loading state +appl.is_loading = function() {appl.def('loading', true)} +appl.not_loading = function() {appl.def('loading', false)} +appl.not_loading() +// Open a modal given by its modal id (uses the modal div's 'id' attribute) +appl.def('open_modal', function(modalId) { + $('.modal').removeClass('inView') + + //$('body').scrollTop(0) + $('#' + modalId).addClass('inView') + + $('body').addClass('is-showingModal') + if (on_ios11()){ + noScroll.on() + } + return appl +}) + +// Close any and all open modals +appl.def('close_modal', function() { + $('.modal').removeClass('inView') + $('body').removeClass('is-showingModal') + if (on_ios11()) { + noScroll.off() + } + return appl +}) + +// Open a given modal id only when the User's Account is confirmed via email +// If the user's account is not confirmed, then show an informational modal +// about confirming their account +appl.def('open_modal_if_confirmed', function(modalId){ + if (app.user && app.user.confirmed) + appl.open_modal(modalId) + else if (app.user && !app.user.confirmed) + appl.open_modal('emailConfirmationModal') + else + appl.open_modal('signUpModal') + return appl +}) + + +// Open a confirmation modal for the user to click 'yes' or 'no' +// Optionally pass in a string message as the first arg (default is 'Are you sure?') +// The last argument is the function to execute when 'yes' is clicked +// Clicking 'no' simply closes the modal +appl.def_lazy('confirm', function() { + var msg, expr, node, self = this + if(arguments.length === 2) { + msg = 'Are you sure?' + expr = arguments[0] + node = arguments[1] + } else { + msg = appl.vs(arguments[0]) + expr = arguments[1] + node = arguments[2] + } + + var result = confirmation(msg) + result.confirmed = function() { appl.vs(expr, node) } + return self +}) + + +// Display a temporary notification message at the bottom of the window +appl.def('notify', function(msg) { + notification(msg) + return appl +}) + +// Convert cents to dollars +appl.def('cents_to_dollars', function(cents) { + return utils.cents_to_dollars(cents) +}) + + +const momentTz = date => + moment.tz(date, "YYYY-MM-DD HH:mm:ss", 'UTC').tz(ENV.nonprofitTimezone || 'UTC') + +// Return a date in the format MM/DD/YY for a given date string or moment obj +appl.def('readable_date', function(date) { + if(!date) return + return momentTz(date).format("MM/DD/YY") +}) + +// Given a created_at string (eg. Charge.last.created_at.to_s), convert it to a readable date-time string +appl.def('readable_date_time', function(date) { + if(!date) return + return momentTz(date).format("MM/DD/YY H:mma z") +}) + +// converts the return value of readable_date_time to it's ISO equivalent +appl.def('readable_date_time_to_iso', date => { + if(!date) return + return moment.tz(date, 'MM/DD/YY H:mma z', ENV.nonprofitTimezone || 'UTC') + .tz('UTC') + .toISOString() +}) + +// Get the month number (eg 01,02...) for the given date string (or moment obj) +appl.def('get_month', function(date) { + var monthNum = moment(date).month() + return moment().month(monthNum).format('MMM') +}) + +// Get the year (eg 2017) for the given date string (or moment obj) +appl.def('get_year', function(date) { + return moment(date).year() +}) + +// Get the day (number in the month) for the given date string (or moment obj) +appl.def('get_day', function(date) { + return moment(date).date() +}) + + +// Get the percentage of x over y +// eg: appl.percentage(34, 69) -> "49.28%" +appl.def('percentage', function(x, y) { + return String(x / y * 100) + '%' +}) + + +// Given a quantity and a plural word describing that quantity, +// return the proper version of that word for that quantitiy +// eg: appl.pluralize(4, 'tomatoes') -> "4 tomatoes" +// appl.pluralize(1, 'donors') -> "1 donor" +appl.def('pluralize', function(quantity, plural_word) { + var str = String(quantity) + ' ' + if(quantity !== 1) return str+plural_word + else return str + appl.to_singular(plural_word) +}) + + +// Convert (most) words from their plural to their singular form +// Works with simple s-endings, ies-endings, and oes-endings +appl.def('to_singular', function(plural_word) { + return plural_word + .replace(/ies$/, 'y') + .replace(/oes$/, 'o') + .replace(/s$/, '') +}) + + +// Truncate a text and add ellipsis to the end +appl.def('append_ellipsis', function(text, length) { + if(text.length <= length) return text + return text.slice(0,length).replace(/ [^ ]+$/, ' ...') +}) + + +// General viewscript utilities +// All of these are to be added to the actual viewscript package in the future + +// Push a given value into the arr given by the property name 'arr_key' +// Mutates the array stored at 'arr_key' +// appl.def('arr', [1,2,3]) +// appl.push('arr', 4) +// appl.arr == [1,2,3,4] +appl.def('push', function(val, arr_key, node) { + var arr = appl.vs(arr_key, node) + if(!arr || !arr.length) arr = [] + arr.push(val) + appl.def(arr_key, arr) + return appl +}) + + +// Concatenate two arrays (this is mutating) +// The first array is given by its property name and will be mutated +// The second array is the array itself to concatenate +// appl.def('arr1', [1,2,3]) +// appl.concat('arr1', [4,5,6]) +// appl.arr1 == [1,2,3,4,5,6] +appl.def('concat', function(arr1_key, arr2, node) { + var arr1 = appl.vs(arr1_key, node) + appl.def(arr1_key, arr1.concat(arr2)) + return appl +}) + + +// Merge all key/vals from set_obj into all objects in the array given by the property 'arr_key' +// eg: +// appl.def('arr_of_objs', [{id: 1, name: 'Bob'}, {id: 2, name: 'Holga'}] +// appl.update_all('arr_of_objs', {name: 'Morty'}) +// appl.arr_of_objs == [{id: 1, name: 'Morty'}, {id: 2, name: 'Morty'}] +appl.def('update_all', function(arr_key, set_obj, node) { + appl.def(arr_key, appl.vs(arr_key).map(function(obj) { + for(var key in set_obj) obj[key] = set_obj[key] + return obj + })) +}) + + +// Given an array of objects in the view state (with property name 'arr_key'), +// and given an object to match on ('obj_matcher'), +// and given an object with values to set ('set_obj'), +// then set each object that matches key/vals in the obj_matcher to the key/vals in set_obj +// +// eg, if val at arr_key is: [{id: 1, name: 'Bob'}, {id: 2, name: 'Holga'}] +// and obj_matcher is: {id: 1} +// and set_obj is: {name: 'Gertrude'} +// then result will be: [{id: 1, name: 'Gertrude'}, {id: 2, name: 'Holga'}] +appl.def('find_and_set', function(arr_key, obj_matcher, set_obj, node) { + var arr = appl.vs(arr_key) + if(!arr) return appl + var result = arr.map(function(obj) { + for (var key in obj_matcher) { + if(obj_matcher[key] === obj[key]) { + return utils.merge(obj, set_obj) + } + } + return obj + }) + appl.def(arr_key, result) + return appl +}) + +appl.def('find_and_remove', function(arr_key, obj_matcher, set_obj, node) { + var arr = appl.vs(arr_key) + if(!arr) return appl + var result = arr.reduce(function(new_arr, obj) { + for (var key in obj_matcher) { + if(obj_matcher[key] === obj[key]) { + return new_arr + } else { + new_arr.push(obj) + return new_arr + } + } + }, []) + appl.def(arr_key, result) + return appl +}) + + +// Return a boolean whether the parent input is checked (must be a type checkbox) +appl.def('is_checked', function(node) { + return appl.prev_elem(node).checked +}) + +// Check a parent input node (must be type checkbox) +appl.def('check', function(node) { + appl.prev_elem(node).checked = true +}) + +// Uncheck a parent input node (must be type checkbox) +appl.def('uncheck', function(node) { + appl.prev_elem(node).checked = false +}) + +// Check the parent node if the predicate is true +appl.def('checked_if', function(pred, node) { + if(pred) appl.prev_elem(node).checked = true + else appl.prev_elem(node).checked = false +}) + +// Remove an attribute from the parent node +appl.def('remove_attr', function(attr, node) { + appl.prev_elem(node).removeAttribute(attr) +}) + +appl.def('remove_attr_if', function(pred, attr, node) { + if(!node) return + var n = appl.prev_elem(node) + if(pred) { + if(!n.hasAttribute('data-attr-' + attr)) n.setAttribute('data-attr-' + attr, n.getAttribute(attr)) // cache attr to add back in + n.removeAttribute(attr) + } else if(!n.hasAttribute(attr)) { + var val = n.getAttribute('data-attr-' + attr) + n.setAttribute(attr, val) + } +}) + +// Map over the given list and update it in the view +appl.transform = function(name, fn) { + var result = appl.vs(name).map(fn) + appl.def(name, result) + return result +} + +// Return the current URL path +appl.def('pathname', function() { return window.location.pathname }) +// Return the root url +appl.def('root_url', function() { return window.location.origin }) + +// Trigger a property to get updated in the view +appl.def('trigger_update', function(prop) { + return appl.def(prop, appl.vs(prop)) +}) + + +appl.def('snake_case', function(string) { + return string.replace(/ /g,'_') +}) + +appl.def('sort_arr_of_objs_by_key', function(arr_of_objs, key) { + return arr_of_objs.sort(function(a, b) { + return a[key].localeCompare(b[key]); + }) +}) + +// Convert a positive integer into an ordinal (1st, 2nd, 3rd...) +appl.def('ordinalize', function(n) { + if(n <= 0) return n + // Deal with the preteen punks first + if([11,12,13].indexOf(n) !== -1) return String(n) + 'th' + var str = String(n) + var lst = str[str.length-1] + if(lst === '1') return String(n) + 'st' + else if(lst === '2') return String(n) + 'nd' + else if(lst === '3') return String(n) + 'rd' + else return String(n) + 'th' +}) + +appl.def('toggle_side_nav', function(){ + if(appl.side_nav_is_open) + appl.def('side_nav_is_open', false) + else + appl.def('side_nav_is_open', true) +}) + +appl.def('head', function(arr) { + if(arr === undefined) return undefined + return arr[0] +}) + +appl.def('select_drop_down', function(node) { + var $li = $(node).parent() + var $dropDown = $li.parents('.dropDown') + $dropDown.find('li').removeClass('is-selected') + $dropDown.find('.dropDown-toggle').removeClass('is-droppedDown') + $li.addClass('is-selected') +}) + +appl.def('clear_drop_down', function(node){ + var $dropDown = $(node).parents('.dropDown') + $dropDown.find('li').removeClass('is-selected') + $dropDown.find('.dropDown-toggle').removeClass('is-droppedDown') +}) + +appl.def('strip_tags', function(html){ + if(!html) return + return html.replace(/(<([^>]+)>)/ig," ") +}) + +appl.def('replace', function(string, matcher, replacer) { + if(!string) return + // the new RegExp constructor takes a string + // and returns a regex: new RegExp("a|b", "i") becomes /a|b/i + return string.replace(new RegExp(matcher, 'g'), replacer) +}) + +appl.def('number_with_commas', function(n){ + if(!n){return} + return utils.number_with_commas(n) +}) + +appl.def('remove_commas', function(s) { + return s.replace(/,/g, '') +}) + +appl.def('percentage', function(x, y, number_of_decimals){ + if(!x || !y) return 0 + number_of_decimals = number_of_decimals || 2 + return Number((y/x * 100).toFixed(number_of_decimals)) +}) + +appl.def('clean_url', function(string){ + return string.replace(/.*?:\/\//g, "") +}) + +appl.def('address_with_commas', function(address, city, state){ + return utils.address_with_commas(address, city, state) +}) + +appl.def('format_phone', function(st) { + return utils.pretty_phone(st) +}) + diff --git a/app/javascript/legacy/common/apply-pikaday.js b/app/javascript/legacy/common/apply-pikaday.js new file mode 100644 index 00000000..c16cc20e --- /dev/null +++ b/app/javascript/legacy/common/apply-pikaday.js @@ -0,0 +1,14 @@ +// License: LGPL-3.0-or-later +const bind = require('attr-binder') +const Pikaday = require('pikaday') +const moment = require('moment') + +bind('apply-pikaday', function(field, format) { + const setDefaultDate = field.getAttribute('pikaday-setDefaultDate') + const maxDate_str = field.getAttribute('pikaday-maxDate') + const maxDate = maxDate_str ? moment(maxDate_str) : undefined + const defaultDate_str = field.getAttribute('pikaday-defaultDate') + const defaultDate = defaultDate_str ? moment(defaultDate_str) : undefined + new Pikaday({format, setDefaultDate, field, maxDate, defaultDate}) +}) + diff --git a/app/javascript/legacy/common/autosubmit.js b/app/javascript/legacy/common/autosubmit.js new file mode 100644 index 00000000..d0425446 --- /dev/null +++ b/app/javascript/legacy/common/autosubmit.js @@ -0,0 +1,72 @@ +// License: LGPL-3.0-or-later +var confirmation = require('./confirmation') +var notification = require('./notification') + +$('form[autosubmit]').submit(function(e) { + var self = this + e.preventDefault() + + if(this.hasAttribute('data-confirm')) { + var result = confirmation() + result.confirmed = function() { + submit_form(e.currentTarget) + } + } else submit_form(e.currentTarget) +}) + +function submit_form(form_el, on_success) { + var path = form_el.getAttribute('action') + var method = form_el.getAttribute('method') + var form_data = new FormData(form_el) + $(form_el).find('button[type="submit"]').loading() + $(form_el).find('.error').text('') + + var notice = form_el.getAttribute('notice') + if(notice) $.cookie('notice', notice, {path: '/'}) + + $.ajax({ + type: method, + url: path, + data: form_data, + dataType: 'json', + processData: false, + contentType: false + }) + .done(function(d) { + if(form_el.hasAttribute('data-reload-with-slug')) + window.location = d['url'] + else if(form_el.hasAttribute('data-reload')) + window.location.reload() + else if(form_el.hasAttribute('data-redirect')) { + var redirect = form_el.getAttribute('data-redirect') + if(redirect) window.location.href = redirect + else if(d.url) window.location.href = d.url + } else { + var msg = form_el.getAttribute('data-success-message') + if(msg) notification(msg) + $(form_el).find('button[type="submit"]').disableLoading() + } + if(on_success) on_success(d) + }) + .fail(function(d) { + $(form_el).find('.error').text(utils.print_error(d)) + $(form_el).find('button[type="submit"]').disableLoading() + }) +} + +// Third closure + +appl.def_lazy('autosubmit', function(callback, node) { + if(!node || !node.parentNode) return + + var self = this, parent = node.parentNode + + parent.onsubmit = function(ev) { + ev.preventDefault() + + if(parent.hasAttribute('data-confirm')) + confirmation().confirmed = function() { submit_form(parent, function() {appl.vs(callback)}) } + else submit_form(parent, function() { appl.vs(callback) }) + } +}) + diff --git a/app/javascript/legacy/common/brand-fonts.js b/app/javascript/legacy/common/brand-fonts.js new file mode 100644 index 00000000..47c8fd93 --- /dev/null +++ b/app/javascript/legacy/common/brand-fonts.js @@ -0,0 +1,8 @@ +// License: LGPL-3.0-or-later +module.exports = { + helvetica: {family: "'Helvetica Neue', Helvetica, Arial, sans-serif", name: 'Helvetica'}, + futura: {family: "'Futura', Arial, sans-serif", name: 'Futura'}, + open: {family: "Open Sans, 'Helvetica Neue', Arial, sans-serif", name: 'Open Sans'}, + georgia: {family: "Georgia, serif", name: 'Georgia'}, + bitter: {family: "'Bitter', serif", name: 'Bitter'} +} diff --git a/app/javascript/legacy/common/class-object.js b/app/javascript/legacy/common/class-object.js new file mode 100644 index 00000000..7bdd32cd --- /dev/null +++ b/app/javascript/legacy/common/class-object.js @@ -0,0 +1,8 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') + +module.exports = (classes='') => R.reduce( + (a, b) => {a[b] = true; return a} + , {} + , R.drop(1, classes.split('.'))) + diff --git a/app/javascript/legacy/common/client.js b/app/javascript/legacy/common/client.js new file mode 100644 index 00000000..e824e804 --- /dev/null +++ b/app/javascript/legacy/common/client.js @@ -0,0 +1,25 @@ +// License: LGPL-3.0-or-later +// superapi wrapper with our api defaults + +var request = require('superagent') + +var wrapper = {} + +wrapper.post = function() { + return request.post.apply(this, arguments).set('X-CSRF-Token', window._csrf).type('json') +} + +wrapper.put = function() { + return request.put.apply(this, arguments).set('X-CSRF-Token', window._csrf).type('json') +} + +wrapper.del = function() { + return request.del.apply(this, arguments).set('X-CSRF-Token', window._csrf).type('json') +} + +wrapper.get = function(path) { + return request.get.call(this, path).accept('json') +} + +module.exports = wrapper + diff --git a/app/javascript/legacy/common/colors.js b/app/javascript/legacy/common/colors.js new file mode 100644 index 00000000..d646e6c1 --- /dev/null +++ b/app/javascript/legacy/common/colors.js @@ -0,0 +1,59 @@ +// License: LGPL-3.0-or-later +module.exports = { + // BLUES + '$dark-turquoise': "#306563" +, '$turquoise': "#a6d5d3" +, '$sea-foam': "#669092" +, '$light-sea-foam': "#5FA6AA" +, '$sky': "#97c2c4" +, '$faded-sky': "#B6D3D4" +, '$light-logo-blue': "#69B4CF" +, '$logo-blue': "#42B3DF" +, '$dark-logo-blue': "#479EBE" +, '$baby-blue': "#E2F8F8" +, '$blue-grey': "rgba(136, 148, 152, 1)" +, '$cloudy': "rgba(67, 164, 202, 0.3)" + + // GREENS +, '$light-grass': "#589E73" +, '$bluegrass': "#5FB88D" +, '$bluegrass--light': "lighten($bluegrass, 10)" +, '$grass': "#2d8f60" +, '$dark-grass': "#35865E" +, '$sage': "#E9F5E8" +, '$sage--dark': "#D5E4D4" +, '$thankYou-green': "#7EC981" +, '$mint': "#DEEFE7" + + // YELLOWS - ORANGES +, '$manila': "#FFFCE3" +, '$pollen': "#F0CD6C" +, '$light-pollen': "#FFE397" +, '$oj': "#FDC785" +, '$looseleaf': "lighten(#FFFEF6, 0.5)" + + // PINKS, REDS, PURPLES +, '$watermelon': "#EE8480" +, '$lavender': "#A57B9E" +, '$blush': "rgba(253, 168, 133, 0.1)" +, '$red': "#FF4F4F" + + // NEUTRALS +, '$charcoal': "#494949" +, '$charcoal--light': "lighten(#494949, 10)" +, '$grey': "rgb(128, 128, 128)" +, '$lightGrey': "lighten($grey, 20)" +, '$sepia': "rgba(65, 65, 65, 0.7)" +, '$shark': "rgb(162, 162, 162)" +, '$fog': "#fbfbfb" +, '$shade': "rgba(0,0,0,0.02)" + +, '$trans': "rgba(255,255,255,0)" + +, '$defaultShadow': "0 0 4px 1px rgba($grey, 0.5)" + + // SOCIAL +, '$facebook': "#236094" +, '$twitter': "#3199cb" +, '$google': "#dd4b39" +} diff --git a/app/javascript/legacy/common/confirmation.js b/app/javascript/legacy/common/confirmation.js new file mode 100644 index 00000000..73a218bd --- /dev/null +++ b/app/javascript/legacy/common/confirmation.js @@ -0,0 +1,46 @@ +// License: LGPL-3.0-or-later +var confirmation = function(msg, success_cb) { + var $confirm_modal = $('#confirmation-modal') + var $msg = $confirm_modal.find('.msg') + if(msg && msg.length > 15) $msg.css('font-size', '16px') + var cb = { + confirmed: function() {}, + denied: function() {} + } + var $previousModal = $('.modal.inView') + $('.modal').removeClass('inView') + var $body = $('body') + $body.addClass('is-showingModal') + + function hide_confirmation_and_show_previous(){ + $('#confirmation-modal').removeClass('inView') + if ($previousModal.length){ + $previousModal.addClass('inView') + $body.addClass('is-showingModal') + } + else + $body.removeClass('is-showingModal') + } + + $confirm_modal.addClass('inView') + .off('click', '.yes') + .off('click', '.no') + + .on('click', '.yes', function(e) { + hide_confirmation_and_show_previous() + if(success_cb) { + success_cb() + } else { + cb.confirmed(e) + } + }) + .on('click', '.no', function(e) { + $('#confirmation-modal').removeClass('inView') + hide_confirmation_and_show_previous() + cb.denied(e) + }) + $msg.text(msg || 'Are you sure?') + return cb +} + +module.exports = confirmation diff --git a/app/javascript/legacy/common/credit-card-validator.js b/app/javascript/legacy/common/credit-card-validator.js new file mode 100644 index 00000000..832e6864 --- /dev/null +++ b/app/javascript/legacy/common/credit-card-validator.js @@ -0,0 +1,25 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') + +// Reference: https://en.wikipedia.org/wiki/Luhn_algorithm + +module.exports = val => { + val = val.replace(/[-\s]/g, '') + return val.match(/^[0-9-\s]+$/) && luhnCheck(val) +} + +const luhnCheck = + R.compose( + R.equals(0) + , R.modulo(R.__, 10) + , R.sum + , R.map(n => n > 9 ? n - 9 : n) // Subtract 9 from those digits greater than 9 + , R.addIndex(R.map)((n, i) => i % 2 === 0 ? n : n * 2) // Double the value of every second digit + , R.map(ch => Number(ch)) + , R.reverse) + +/* +Luhn check in haskell: +luhn = (0 ==) . (`mod` 10) . sum . map (uncurry (+) . (`divMod` 10)) . + zipWith (*) (cycle [1,2]) . map digitToInt . reverse +*/ diff --git a/app/javascript/legacy/common/css-gradient.js b/app/javascript/legacy/common/css-gradient.js new file mode 100644 index 00000000..29ffcee8 --- /dev/null +++ b/app/javascript/legacy/common/css-gradient.js @@ -0,0 +1,8 @@ +// License: LGPL-3.0-or-later +module.exports = (dir, to, from) => +` background-image: -webkit-linear-gradient(${dir}, ${to}, ${from}); +background-image: -moz-linear-gradient(${dir}, ${to}, ${from}); +background-image: -ms-linear-gradient(${dir}, ${to}, ${from}); +background-image: linear-gradient(${dir}, ${to}, ${from}); +filter: progid:DXImageTransform.Microsoft.gradient(GradientType=1,startColorstr=${to}, endColorstr=${from});` + diff --git a/app/javascript/legacy/common/direct-to-s3-upload.es6 b/app/javascript/legacy/common/direct-to-s3-upload.es6 new file mode 100644 index 00000000..6e5adf54 --- /dev/null +++ b/app/javascript/legacy/common/direct-to-s3-upload.es6 @@ -0,0 +1,41 @@ +// License: LGPL-3.0-or-later +const flyd = require('flyd') +const R = require('ramda') + + +// local +const request = require('./super-agent-frp') +const postFormData = require('./post-form-data') + + +// Pass in a stream of Input Nodes with type file +// Make a post request to our server to start the import +// Will create a backgrounded job and email the user when +// completed +// Returns a stream of {uri: 'uri of uploaded file on s3', formData: 'original form data'} +const uploadFile = R.curry(input => { + // We need to get an AWS presigned post thing to so we can upload files + // Stream of pairs of [formObjData, presignedPostObj] + var withPresignedPost$ = flyd.map( + resp => [input, resp.body] + , request.post('/aws_presigned_posts').perform() + ) + + // Stream of upload responses from s3 + return flyd.flatMap( + pair => { + var [input, presignedPost] = pair + var url = `https://${presignedPost.s3_direct_url.host}` + var file = input.files[0] + var fileUrl = `${url}/tmp/${presignedPost.s3_uuid}/${file.name}` + var urlWithPort = `${url}:${presignedPost.s3_direct_url.port}` + var payload = R.merge(JSON.parse(presignedPost.s3_presigned_post), {file}) + + return flyd.map(resp => ({uri: fileUrl, file}), postFormData(url, payload)) + } + , withPresignedPost$) +}) + + +module.exports = uploadFile + diff --git a/app/javascript/legacy/common/dynamic_form.js b/app/javascript/legacy/common/dynamic_form.js new file mode 100644 index 00000000..ffe66c8e --- /dev/null +++ b/app/javascript/legacy/common/dynamic_form.js @@ -0,0 +1,30 @@ +// License: LGPL-3.0-or-later +var notification = require('./notification') + +$('form.dynamic').submit(function(e) { + var self = this + e.preventDefault() + var path = this.getAttribute('action') + var meth = this.getAttribute('method') + var form_data = new FormData(this) + $(this).find('button[type="submit"]').loading() + + $.ajax({ + type: meth, + url: path, + data: form_data, + dataType: 'json', + processData: false, + contentType: false + }) + .done(function(d) { + $('.modal').modal('hide') + notification(d.notification) + }) + .fail(function(d) { + $(self).find('.error').text(utils.print_error(d)) + }) + .complete(function() { + $(self).find('button[type="submit"]').disableLoading() + }) +}) diff --git a/app/javascript/legacy/common/editable.js b/app/javascript/legacy/common/editable.js new file mode 100644 index 00000000..2a0fb863 --- /dev/null +++ b/app/javascript/legacy/common/editable.js @@ -0,0 +1,10 @@ +// License: LGPL-3.0-or-later +// if you are instantiating more than one WYSIWYG on a page, +// be sure to give them id's to differentiate them +// to avoid unwanted display side effects + + +if (app.editor === 'froala') + module.exports = require('./editor/froala.es6') +else if (app.editor === 'quill') + module.exports = require('./editor/quill.es6') diff --git a/app/javascript/legacy/common/editor/froala.es6 b/app/javascript/legacy/common/editor/froala.es6 new file mode 100644 index 00000000..329a8b9d --- /dev/null +++ b/app/javascript/legacy/common/editor/froala.es6 @@ -0,0 +1,150 @@ +// License: LGPL-3.0-or-later +var view = require("vvvview") +var savingIndicator = require('../../components/saving_indicator') +var savingState = {hide: true} +var renderSavingIndicator = view(savingIndicator, document.body, savingState) + +var donate_button_markup = "Donate" +else + donate_button_markup += ">Donate" + +var email_buttons = ["bold", "italic", "formatBlock", "align", "createLink", + "insertImage", "insertUnorderedList", "insertOrderedList", + "undo", "redo", "insert_donate_button", "insert_name", "html"] + +var froala = function($el, options) { + $el.editable({ + key: app.froala_key, + placeholder: options.placeholder || 'Edit text here', + buttons: options.email_buttons ? email_buttons : options.buttons ? options.buttons : ["bold", "italic", "formatBlock", "align", "createLink", "insertImage", "insertVideo", "insertUnorderedList", "insertOrderedList", "undo", "redo", "html"], + inlineMode: false, + beautifyCode: true, + plainPaste: true, + blockTags: {p: 'Normal', h5: "Heading", small: 'Caption'}, + allowedAttrs: ["accept","accept-charset","accesskey","action","align","alt","async","autocomplete","autofocus","autoplay","autosave","background","bgcolor","border","charset","cellpadding","cellspacing","checked","cite","class","color","cols","colspan","content","contenteditable","contextmenu","controls","coords","data","data-.*","datetime","default","defer","dir","dirname","disabled","download","draggable","dropzone","enctype","for","form","formaction","headers","height","hidden","high","href","hreflang","http-equiv","icon","id","ismap","itemprop","keytype","kind","label","lang","language","list","loop","low","max","maxlength","media","method","min","multiple", "muted", "name","novalidate","open","optimum","pattern","ping","placeholder","poster","preload","pubdate","radiogroup","readonly","rel","required","reversed","rows","rowspan","sandbox","scope","scoped","scrolling","seamless","selected","shape","size","sizes","span","src","srcdoc","srclang","srcset","start","step","summary","spellcheck","style","tabindex","target","title","type","translate","usemap","value","valign","width","wrap"], + imageUploadURL: '/image_attachments.json', + imageUploadParams: { + authenticity_token: $("meta[name='csrf-token']").attr('content') + }, + imageDeleteURL: '/image_attachments/remove.json', + imageErrorCallback: function (d) { + }, + afterRemoveImageCallback: function ($img) { + this.options.imageDeleteParams = {src: $img.attr('src')}; + this.deleteImage($img); + }, + customButtons: { + format_code: { + title: 'format code', + icon: { + type: 'font', + value: 'fa fa-bolt' + }, + callback: function () { + // used to show code snippets. + // takes selected text, including typed html tags + // and wraps each text line in a
+ // and appends all of the
s into a
 tag
+                    // and then replaces that selected text with the
+                    // newly created 
 tag
+
+                    var lines_of_code = this.text().split("\n")
+                    var pre = document.createElement('pre')
+                    pre.className = 'codeText'
+
+                    // created 
s for each new line and appends them to
+                    lines_of_code.map(function (line) {
+                        var div = document.createElement('div')
+                        div.appendChild(document.createTextNode(line))
+                        pre.appendChild(div)
+                    })
+
+                    var selected_elements = this.getSelectionElements()
+                    var first_selected_element = selected_elements[0]
+                    var parent_node = document.getElementsByClassName('froala-element')[0]
+
+                    // inserts pre before selection
+                    parent_node.insertBefore(pre, first_selected_element)
+
+                    // inserts 
s before and after
+                    parent_node.insertBefore(document.createElement('br'), pre)
+                    parent_node.insertBefore(document.createElement('br'), pre.nextSibling)
+
+                    // deletes selection
+                    selected_elements.map(function (el) {
+                        parent_node.removeChild(el)
+                    })
+
+                    this.saveUndoStep()
+                }
+            },
+            insert_donate_button: {
+                title: 'Donate Button',
+                icon: {
+                    type: 'font',
+                    value: 'fa fa-heart'
+                },
+                callback: function () {
+                    this.insertHTML(donate_button_markup)
+                    this.saveUndoStep()
+                },
+                refresh: function () {
+                }
+            },
+            insert_name: {
+                title: 'Insert recipient name',
+                icon: {
+                    type: 'txt',
+                    value: 'Name'
+                },
+                callback: function () {
+                    this.insertHTML("{{NAME}}")
+                    this.saveUndoStep()
+                },
+                refresh: function () {
+                }
+            },
+        },
+      videoAllowedAttrs: ["src","width","height","frameborder","allowfullscreen","webkitallowfullscreen","mozallowfullscreen","href","target","id","controls","value","name", "autoplay", "loop", "muted"]
+    })
+
+    $('.froala-popup').parents('.froala-editor').css('z-index', 99999)
+
+    if (!options.noUpdateOnChange) {
+        $el.on('editable.contentChanged', function (e, editor) {
+            utils.delay(100, function () {
+                var key = $el.data('key')
+                var data = {}
+                var path = $el.data('path')
+                data[key] = $el.find('.froala-element').html()
+                renderSavingIndicator({hide: false, text: 'Saving...'})
+                $.ajax({type: 'put', url: path, data: data})
+                    .done(function () {
+                        renderSavingIndicator({text: 'Saved'})
+                        window.setTimeout(function () {
+                            renderSavingIndicator({hide: true})
+                        }, 500)
+                    })
+            })
+        })
+    }
+
+    if (options.sticky) {
+        window.onload = function () {
+            var makeEditorStick = require('../scroll_toggle_class')
+            var id = $el.attr('id') ? '#' + $el.attr('id') : false
+            var parent = id ? id : '.froala-box'
+            var child = id ? id + ' .froala-editor' : '.froala-editor'
+            makeEditorStick(child, 'is-stuck', parent)
+            $(child).css('width', $(parent).width())
+            $(window).resize(function () {
+                $(child).css('width', $(parent).width())
+            })
+        }
+    }
+}
+
+module.exports = froala;
\ No newline at end of file
diff --git a/app/javascript/legacy/common/editor/quill.es6 b/app/javascript/legacy/common/editor/quill.es6
new file mode 100644
index 00000000..9c2a6517
--- /dev/null
+++ b/app/javascript/legacy/common/editor/quill.es6
@@ -0,0 +1,45 @@
+// License: LGPL-3.0-or-later
+var view = require("vvvview")
+var savingIndicator = require('../../components/saving_indicator')
+var savingState = {hide: true}
+var renderSavingIndicator = view(savingIndicator, document.body, savingState)
+
+
+const Quill = require('quill')
+
+function initializeQuill($el, options)
+{
+    var editor = new Quill($el, {
+        theme: 'bubble',
+        placeholder: options.placeholder
+    });
+
+    if (!options.noUpdateOnChange) {
+        editor.on('text-change', function () {
+            utils.delay(100, function () {
+                var key = $el.getAttribute('data-key')
+                var data = {}
+                var path = $el.getAttribute('data-path')
+                data[key] = editor.root.innerHTML
+                renderSavingIndicator({hide: false, text: 'Saving...'})
+                $.ajax({type: 'put', url: path, data: data})
+                    .done(function () {
+                        renderSavingIndicator({text: 'Saved'})
+                        window.setTimeout(function () {
+                            renderSavingIndicator({hide: true})
+                        }, 500)
+                    })
+            })
+        })
+    }
+}
+
+
+var quill = function($el, options) {
+    for (var i =0; i < $el.length; i++)
+    {
+        initializeQuill($el[i], options)
+    }
+}
+
+module.exports = quill
\ No newline at end of file
diff --git a/app/javascript/legacy/common/el_swapo.js b/app/javascript/legacy/common/el_swapo.js
new file mode 100644
index 00000000..9047af10
--- /dev/null
+++ b/app/javascript/legacy/common/el_swapo.js
@@ -0,0 +1,30 @@
+// License: LGPL-3.0-or-later
+var el_swapo = {}
+
+$('*[swap-in]').each(function(i) {
+	var self = this
+
+	$(this).on('click', function(e) {
+		var swap_class = self.getAttribute('swap-class')
+		var new_class = self.getAttribute('swap-in')
+		swap(swap_class, new_class)
+	})
+})
+
+function swap(swap_class, new_class) {
+	$('*[swap-class="' + swap_class + '"]').removeClass('active')
+	$('*[swap-in="' + new_class + '"]').addClass('active')
+	$('.' + swap_class).hide()
+	$('.' + new_class).fadeIn()
+	utils.change_url_param('p', new_class)
+	utils.change_url_param('s', swap_class)
+}
+
+var current_page = utils.get_param('p')
+var current_swap = utils.get_param('s')
+if(current_page && current_swap) {
+	swap(current_swap, current_page)
+  setTimeout(() => document.querySelector(`[swap-in='${current_page}']`).click(), 400)
+}
+
+module.exports = el_swapo
diff --git a/app/javascript/legacy/common/event.js b/app/javascript/legacy/common/event.js
new file mode 100644
index 00000000..934f1c87
--- /dev/null
+++ b/app/javascript/legacy/common/event.js
@@ -0,0 +1,14 @@
+// License: LGPL-3.0-or-later
+var actions = [ 'change', 'click', 'dblclick', 'mousedown', 'mouseup', 'mouseenter', 'mouseleave', 'scroll', 'blur', 'focus', 'input', 'submit', 'keydown', 'keypress', 'keyup' ]
+
+function event(id, fn) {
+	// Find all classes ending in the event id
+	actions.forEach(function(action) {
+		$('*[on-' + action + '="' + id + '"]').each(function() {
+			if(this.getAttribute('on-' + action).indexOf(id) !== -1)
+				$(this).on(action, fn)
+		})
+	})
+}
+
+module.exports = event
diff --git a/app/javascript/legacy/common/ff-form-validation/index.es6 b/app/javascript/legacy/common/ff-form-validation/index.es6
new file mode 100644
index 00000000..bbd6ea85
--- /dev/null
+++ b/app/javascript/legacy/common/ff-form-validation/index.es6
@@ -0,0 +1,180 @@
+// License: LGPL-3.0-or-later
+const h = require('snabbdom/h')
+const R = require('ramda')
+const flyd = require('flyd')
+const serializeForm = require('form-serialize')
+flyd.filter = require('flyd/module/filter')
+flyd.mergeAll = require('flyd/module/mergeall')
+flyd.keepWhen = require('flyd/module/keepwhen')
+flyd.sampleOn = require('flyd/module/sampleon')
+const readableProp = require('./lib/readable-prop.es6')
+const emailRegex = require('./lib/email-regex.es6')
+const currencyRegex = require('./lib/currency-regex.es6')
+
+// constraints: a hash of key/vals where each key is the name of an input
+// and each value is an object of validator names and arguments
+//
+// validators: a hash of validation names mapped to boolean functions
+//
+// messages: a hash of validator names and field names mapped to error messages 
+//
+// messages match on the most specific thing in the messages hash
+// - first checks if there is an exact match on field name
+// - then checks for match on validator name
+//
+// Given a constraint like:
+// {name: {required: true}}
+//
+// All of the following will set an error for above, starting with most specific first:
+// {name: {required: 'Please enter a valid name'}
+// {name: 'Please enter your name'}
+// {required: 'This is required'}
+
+
+function init(state) {
+  state = R.merge({
+    validators: R.merge(defaultValidators, state.validators || {})
+  , messages: R.merge(defaultMessages, state.messages || {})
+  , focus$:  flyd.stream()
+  , change$: flyd.stream()
+  , submit$: flyd.stream()
+  }, state || {})
+  const valField = validateField(state)
+  const valForm  = validateForm(state)
+
+  const fieldErr$ = flyd.map(valField, state.change$)
+  const formErr$ = flyd.map(valForm, state.submit$)
+  const clearErr$ = flyd.map(ev => [ev.target.name, null], state.focus$)
+  const allErrs$ = flyd.mergeAll([fieldErr$, formErr$, clearErr$])
+
+  // Stream of all errors combined into one object
+  state.errors$ = flyd.scan(R.assoc, {}, allErrs$)
+
+  // Stream of field names and new values and whole form data
+  state.nameVal$ = flyd.map(node => [node.name, node.value], state.change$)
+  state.data$ = flyd.scan(R.assoc, {}, state.nameVal$)
+
+  // Streams of errors and data on form submit
+  const errorsOnSubmit$ = flyd.sampleOn(state.submit$, state.errors$)
+  state.validSubmit$ = flyd.filter(R.compose(R.none, R.values), errorsOnSubmit$)
+  state.validData$ = flyd.keepWhen(state.validSubmit$, state.data$)
+  
+  return state
+}
+
+
+// Pass in an array of validation functions and the event object
+// Will return a pair of [name, errorMsg] (errorMsg will be null if no errors present)
+const validateField = R.curry((state, node) => {
+  const value = node.value
+  const name = node.name
+  if(!state.constraints[name]) return [name, null] // no validators for this field present
+
+  // Find the first constraint that fails its validator 
+  for(var valName in state.constraints[name]) {
+    const arg = state.constraints[name][valName]
+    if(!state.validators[valName]) {
+      console.warn("Form validation constraint does not exist:", valName)
+    } else if(!validators[valName](value, arg)) {
+      const msg = getErr(messages, name, valName, arg)
+      return [name, String(msg)]
+    }
+  }
+  return [name, null] // no error found
+})
+
+
+// Given the messages object, the validator argument, the field name, and the validator name
+// Retrieve and apply the error message function
+const getErr = (messages, name, valName, arg) => {
+  const err = messages[name] 
+    ? messages[name][valName] || messages[name]
+    : messages[valName]
+  if(typeof err === 'function') return err(arg)
+  else return err
+}
+
+
+// Retrieve errors for the entire set of form data, used on form submit events,
+// using the form data saved into the state
+const validateForm = R.curry((state, node) => {
+  const formData = serializeForm(node, {hash: true})
+  for(var fieldName in constraints) { // not using higher order .map or reduce so we can break and return early
+    for(var valName in constraints[fieldName]) {
+      const arg = constraints[fieldName][valName]
+      if(!validators[valName]) {
+        console.warn("Form validation constraint does not exist:", valName)
+      } else if(!validators[valName](value, arg)) {
+        const msg = getErr(messages, name, valName, arg)
+        return [name, String(msg)]
+      }
+    }
+  }
+}
+
+
+// -- Views
+
+const validatedForm = R.curry((state, elm) => {
+  elm.data = R.merge(elm.data, {
+    on: {submit: ev => {ev.preventDefault(); state.submit$(ev.currentTarget)}}
+  })
+  return elm
+})
+
+
+// A single form field
+// Data takes normal snabbdom data for the input/select/textarea (eg props, style, on)
+const validatedField = R.curry((state, elm) => {
+  if(!elm.data.props || !elm.data.props.name) throw new Error(`You need to provide a field name for validation (using the 'props.name' property)`)
+  var err = state.errors$()[elm.data.props.name]
+  var invalid = err && err.length
+
+  elm.data = R.merge(elm.data, {
+    on: {
+      focus: state.focus$
+    , change: ev => state.change$([ev.currentTarget, state])
+    }
+  , class: { invalid }
+  })
+
+  return h('div', {
+    props: {className: 'ff-field' + (invalid ? ' ff-field--invalid' : ' ff-field--valid')}
+  }, [
+    invalid ? h('p.ff-field-errorMessage', err) : ''
+  , elm
+  ])
+})
+
+var defaultMessages = {
+  email: 'Please enter a valid email address'
+, required: 'This field is required'
+, currency: 'Please enter valid currency'
+, format: "This doesn't look like the right format"
+, isNumber: 'This should be a number'
+, max: n => `This should be less than ${n}`
+, min: n => `This should be at least ${n}`
+, equalTo: n => `This should be equal to ${n}`
+, maxLength: n => `This should be no longer than ${n}`
+, minLength: n => `This should be longer than ${n}`
+, lengthEquals: n => `This should have a length of ${n}`
+, includedIn: arr => `This should be one of: ${arr.join(', ')}`
+}
+
+var defaultValidators = {
+  email: val => val.match(emailRegex)
+, present: val => Boolean(val)
+, currency: val => String(val).match(currencyRegex)
+, format: (val, arg) => String(val).match(arg)
+, isNumber: val => !isNaN(val)
+, max: (val, n) => val <= n
+, min: (val, n) => val >= n
+, equalTo:  (val, n) => n === val
+, maxLength: (val, n) => val.length <= n
+, minLength: (val, n) => val.length >= n
+, lengthEquals: (val, n) => val.length === n
+, includedIn: (val, arr) => arr.indexOf(val) !== -1
+}
+
+module.exports = {init, validatedField, validatedForm}
+
diff --git a/app/javascript/legacy/common/ff-form-validation/lib/currency-regex.es6 b/app/javascript/legacy/common/ff-form-validation/lib/currency-regex.es6
new file mode 100644
index 00000000..378586ca
--- /dev/null
+++ b/app/javascript/legacy/common/ff-form-validation/lib/currency-regex.es6
@@ -0,0 +1,3 @@
+// License: LGPL-3.0-or-later
+module.exports = /^(?!0\.00)\d{1,3}(,\d{3})*(\.\d\d)?$/
+
diff --git a/app/javascript/legacy/common/ff-form-validation/lib/email-regex.es6 b/app/javascript/legacy/common/ff-form-validation/lib/email-regex.es6
new file mode 100644
index 00000000..624a9afa
--- /dev/null
+++ b/app/javascript/legacy/common/ff-form-validation/lib/email-regex.es6
@@ -0,0 +1,4 @@
+// License: LGPL-3.0-or-later
+// http://stackoverflow.com/questions/46155/validate-email-address-in-javascript
+module.exports = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+ 
diff --git a/app/javascript/legacy/common/ff-form-validation/lib/readable-prop.es6 b/app/javascript/legacy/common/ff-form-validation/lib/readable-prop.es6
new file mode 100644
index 00000000..2f1985ff
--- /dev/null
+++ b/app/javascript/legacy/common/ff-form-validation/lib/readable-prop.es6
@@ -0,0 +1,12 @@
+// License: LGPL-3.0-or-later
+// "email_address" => "email address"
+// "emailAddress" => "email address"
+
+module.exports = str =>
+  str
+  .replace('_', ' ')
+  .replace(/([a-z])([A-Z])/g, '$1 $2')
+  .replace(/\b([A-Z]+)([A-Z])([a-z])/, '$1 $2$3')
+  .toLowerCase()
+
+
diff --git a/app/javascript/legacy/common/file-input-stream.js b/app/javascript/legacy/common/file-input-stream.js
new file mode 100644
index 00000000..4f506b04
--- /dev/null
+++ b/app/javascript/legacy/common/file-input-stream.js
@@ -0,0 +1,17 @@
+// License: LGPL-3.0-or-later
+const flyd = require('flyd')
+const R = require('ramda')
+
+// Given an input element, return a stream of the input file data as text
+
+module.exports = R.curry(node => {
+  var $stream = flyd.stream()
+  var file = node.files[0]
+  var reader = new FileReader()
+  if(file instanceof Blob) {
+    reader.readAsText(file)
+    reader.onload = e => $stream(reader.result)
+  }
+  return $stream
+})
+
diff --git a/app/javascript/legacy/common/form-to-object.js b/app/javascript/legacy/common/form-to-object.js
new file mode 100644
index 00000000..8321ff9f
--- /dev/null
+++ b/app/javascript/legacy/common/form-to-object.js
@@ -0,0 +1,44 @@
+// License: LGPL-3.0-or-later
+// Convert a form to an object literal
+module.exports = function(form) {
+	if(form === undefined) throw new Error("form is undefined")
+	var result = {}
+	var fields = toArr(form.querySelectorAll('input, textarea, select'))
+  .filter(function(n) { return n.hasAttribute('name') })
+  .map(function(n) {
+    var name = n.getAttribute('name')
+    var keys = n.getAttribute('name').split('.')
+    if(n.value && n.value.toString().length) { // won't set empty strings for empty vals
+      if(n.getAttribute('type') === 'checkbox') {
+        deepSet(keys, n.checked, result)
+      } else if(n.getAttribute('type') === 'radio') {
+        if(n.checked) deepSet(keys, n.value, result)
+      } else {
+        deepSet(keys, n.value, result)
+      }
+    }
+  })
+
+	return result
+}
+
+function toArr(x) { return Array.prototype.slice.call(x) }
+
+// Given an array of nested keys, a value, and a target object:
+// Set the value into the object at the last nested key
+function deepSet(keys, val, obj, options) {
+	var exceptLast = keys.slice(0, keys.length-1)
+	var last = keys[keys.length-1]
+	var nested = exceptLast.reduce(function(nestedObj, key) {
+		if(nestedObj[key] === undefined) {
+			nestedObj[key] = {}
+			return nestedObj[key]
+		} else {
+			return nestedObj[key]
+		}
+	}, obj)
+	// if(nested[last] === undefined) nested[last] = {}
+	nested[last] = val
+	return obj
+}
+
diff --git a/app/javascript/legacy/common/form.js b/app/javascript/legacy/common/form.js
new file mode 100644
index 00000000..ace9efcc
--- /dev/null
+++ b/app/javascript/legacy/common/form.js
@@ -0,0 +1,20 @@
+// License: LGPL-3.0-or-later
+var form = module.exports = {
+	loading: loading,
+	showErr: showErr,
+	clear: clear
+}
+
+function loading(formEl) {
+	$(formEl).find('button[type="submit"]').loading()
+}
+
+function showErr(msg, el) {
+	$(el).find('.status').addClass('error').text(msg)
+	$(el).find('button[type="submit"]').disableLoading()
+}
+
+function clear(el) {
+	$(el).find('.status').removeClass('error').text('')
+	$(el).find('button[type="submit"]').disableLoading()
+}
diff --git a/app/javascript/legacy/common/format.js b/app/javascript/legacy/common/format.js
new file mode 100644
index 00000000..dc4fe828
--- /dev/null
+++ b/app/javascript/legacy/common/format.js
@@ -0,0 +1,126 @@
+// License: LGPL-3.0-or-later
+var moment = require('moment')
+var format = {}
+
+module.exports = format
+
+// Convert a snake-case phrase (eg. 'requested_by_customer') to a readable phrase (eg. 'Requested by customer')
+format.snake_to_words = function(snake, options) {
+	if(!snake) return snake
+	return snake.replace(/_/g, ' ').replace(/^./, function(m) {return m.toUpperCase()})
+}
+
+format.camelToWords = function(str, os) {
+  if(!str) return str
+  return str.replace(/([A-Z])/g, " $1")
+}
+
+format.dollarsToCents = function(dollars) {
+	dollars = dollars.toString().replace(/[$,]/g, '')
+    if(!isNaN(dollars) && dollars.match(/^-?\d+\.\d$/)) {
+        // could we use toFixed instead? Probably but this is straightforward.
+        dollars = dollars + "0"
+    }
+	if(isNaN(dollars) || !dollars.match(/^-?\d+(\.\d\d)?$/)) throw "Invalid dollar amount: " + dollars
+  return Math.round(Number(dollars) * 100)
+}
+
+format.centsToDollars = function(cents, options={}) {
+	if(cents === undefined) return '0'
+	return format.numberWithCommas((Number(cents) / 100.0).toFixed(options.noCents ? 0 : 2).toString()).replace(/\.00$/,'')
+}
+
+format.weeklyToMonthly = function(amount) {
+  if (amount === undefined) return 0;
+  return Math.round(4.3 * amount);
+}
+
+
+
+format.numberWithCommas = function(n) {
+  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
+}
+
+format.percent = function(x, y) {
+  if(!x || !y) return 0
+  return Math.round(y / x * 100)
+}
+
+format.pluralize = function(quantity, plural_word) {
+	if(quantity === undefined || quantity === null) return '0 '+plural_word
+	var str = String(quantity) + ' '
+	if(quantity !== 1) return str+plural_word
+	else return str + appl.to_singular(plural_word)
+}
+
+format.capitalize = function (string) {
+  return string.split(' ')
+    .map(function(s) { return s.charAt(0).toUpperCase() + s.slice(1) })
+    .join(' ')
+}
+
+format.toSentence = function(arr) {
+	if(arr.length < 2) return arr
+	if(arr.length === 2) return arr[0] + ' and ' + arr[1]
+	var last = arr.length - 1
+	return arr.slice(0, last).join(', ') + ', and ' + arr[last]
+}
+
+format.zeroPad = function(num, size) {
+	var str = num + ""
+	while(str.length < size) str = "0" + str
+	return str
+}
+
+format.sanitizeHtml = function(html) {
+  if(!html) return
+  var tagBody = '(?:[^"\'>]|"[^"]*"|\'[^\']*\')*'
+  var tagOrComment = new RegExp(
+    '<(?:'
+      // Comment body.
+      + '!--(?:(?:-*[^->])*--+|-?)'
+      // Special "raw text" elements whose content should be elided.
+      + '|script\\b' + tagBody + '>[\\s\\S]*?[\\s\\S]*?',
+    'gi')
+  return html.replace(tagOrComment, '').replace(/ {
+  const url = '/'
+  const response$ = request({method: 'GET', url, path, query}).load
+  const valid$ = flyd.filter(x => x.status === 200, response$)
+  return flyd.map(x => x.body, valid$)
+}
+
diff --git a/app/javascript/legacy/common/image_uploader.js b/app/javascript/legacy/common/image_uploader.js
new file mode 100644
index 00000000..7bec14a4
--- /dev/null
+++ b/app/javascript/legacy/common/image_uploader.js
@@ -0,0 +1,34 @@
+// License: LGPL-3.0-or-later
+$('.image-upload input').change(function(e) {
+	var self = this
+	appl.def('image_upload.is_selecting', true)
+	if(this.files && this.files[0]) {
+		var reader = new FileReader()
+		reader.onload = function(e) {
+			if(e.valueOf().loaded >= 3000000) {
+				appl.def('image_upload.error', 'Please select a file smaller than 3mb')
+			} else {
+				appl.def('image_upload.error', '')
+			}
+			$(self).parent().css('background-image', "url('" + e.target.result + "')")
+			$(self).parent().addClass('live-preview')
+		}
+		reader.readAsDataURL(this.files[0])
+	}
+})
+
+appl.def('remove_image', function(url, resource, notification, payload) {
+	var data = {}
+	data[resource] = payload
+	appl.notify(notification)
+	appl.def('loading', true)
+	$.ajax({
+		type: 'put',
+		url: url,
+		data: data,
+	})
+		.done(function() {
+			appl.reload()
+		})
+		.fail(function(e) { })
+})
diff --git a/app/javascript/legacy/common/jquery_additions.js b/app/javascript/legacy/common/jquery_additions.js
new file mode 100644
index 00000000..640f137a
--- /dev/null
+++ b/app/javascript/legacy/common/jquery_additions.js
@@ -0,0 +1,32 @@
+// License: LGPL-3.0-or-later
+$.fn.serializeObject = function() {
+	return this.serializeArray().reduce(function(obj, field) {
+		if(field.value)
+			var val = field.value
+		else if(field.files && field.files[0])
+			var val = field.files[0]
+		obj[field.name] = val
+		return obj 
+	}, {})
+}
+
+// Make a button enter the ajax loading state, where it's disabled and has a little spinner.
+$.fn.loading = function(message) {
+	this.each(function() {
+		var msg = message || this.getAttribute('data-loading')
+		this.setAttribute('data-text', this.innerHTML)
+		this.innerHTML = " " + msg
+		this.setAttribute('disabled', 'disabled')
+	})
+	return this
+}
+
+$.fn.disableLoading = function() {
+	this.each(function() {
+		if(!this.hasAttribute('disabled')) return
+		var old_text = this.getAttribute('data-text')
+		this.innerHTML = old_text
+		this.removeAttribute('disabled')
+	})
+	return this
+}
diff --git a/app/javascript/legacy/common/notification.js b/app/javascript/legacy/common/notification.js
new file mode 100644
index 00000000..4ca5c0b5
--- /dev/null
+++ b/app/javascript/legacy/common/notification.js
@@ -0,0 +1,13 @@
+// License: LGPL-3.0-or-later
+var notification = function(msg, err) {
+	var el = document.getElementById('js-notification')
+  if(err) {el.className = 'show error'} 
+  else {el.className = 'show'}
+  el.innerText = msg
+	window.setTimeout(function() {
+		el.className = ''
+    el.innerText = ''
+	}, 7000)
+}
+module.exports = notification
+
diff --git a/app/javascript/legacy/common/on-change-sanitize-slug.js b/app/javascript/legacy/common/on-change-sanitize-slug.js
new file mode 100644
index 00000000..126dde2a
--- /dev/null
+++ b/app/javascript/legacy/common/on-change-sanitize-slug.js
@@ -0,0 +1,11 @@
+// License: LGPL-3.0-or-later
+const R = require('ramda')
+const sanitize = require('./sanitize-slug')
+
+// Just a hacky way to automatically sanitize slug inputs when they are changed
+
+var inputs = document.querySelectorAll('.js-sanitizeSlug')
+
+R.map(
+  inp => inp.addEventListener('change', ev => ev.currentTarget.value = sanitize(ev.currentTarget.value || ev.currentTarget.getAttribute('data-slug-default')))
+, inputs )
diff --git a/app/javascript/legacy/common/on-ios11.js b/app/javascript/legacy/common/on-ios11.js
new file mode 100644
index 00000000..99728c18
--- /dev/null
+++ b/app/javascript/legacy/common/on-ios11.js
@@ -0,0 +1,11 @@
+// License: LGPL-3.0-or-later
+function calculateIOS()
+{
+    var userAgent = window.navigator.userAgent;
+    var has11 = userAgent.search("OS 11_\\d") > 0
+    var hasMacOS = userAgent.search(" like Mac OS X") > 0
+
+    return has11 && hasMacOS;
+}
+
+module.exports = calculateIOS
\ No newline at end of file
diff --git a/app/javascript/legacy/common/onboard.js b/app/javascript/legacy/common/onboard.js
new file mode 100644
index 00000000..bdcfbbc9
--- /dev/null
+++ b/app/javascript/legacy/common/onboard.js
@@ -0,0 +1,280 @@
+// License: LGPL-3.0-or-later
+const flyd = require('flimflam/flyd')
+const h = require('flimflam/h')
+const R = require('ramda')
+const modal = require('flimflam/ui/modal')
+const render = require('flimflam/render')
+const wizard = require('flimflam/ui/wizard')
+const validatedForm = require('flimflam/ui/validated-form')
+const request = require('./request')
+const notification = require('flimflam/ui/notification')
+const fieldWithError = require('../components/field-with-error')
+
+const init = () => {
+  const orgForm = validatedForm.init({constraints: constraints.org})
+  const contactForm = validatedForm.init({constraints: constraints.contact})
+  const infoForm = validatedForm.init({constraints: constraints.info})
+  const currentStep$ = flyd.mergeAll([
+    flyd.stream(0)
+  , flyd.map(R.always(1), orgForm.validSubmit$)
+  , flyd.map(R.always(2), infoForm.validSubmit$)
+  ])
+  const wiz = wizard.init({currentStep$})
+  const openModal$ = flyd.stream()
+  document.querySelectorAll('[data-ff-open-onboard]')
+    .forEach(x => {x.addEventListener('click', openModal$)})
+
+  //flyd.map(trackGA, openModal$)
+
+  const response$ = flyd.flatMap(postData(orgForm, infoForm, contactForm), contactForm.validData$)
+  const respOk$ = flyd.filter(resp => resp.status === 200, response$)
+  const respErr$ = flyd.filter(resp => resp.status !== 200, response$)
+  const loading$ = flyd.mergeAll([
+    flyd.map(R.always(true), contactForm.validSubmit$)
+  , flyd.map(R.always(false), response$)
+  ])
+
+  const message$ = flyd.mergeAll([
+    flyd.map(R.always("Saving your data..."), contactForm.validSubmit$)
+  , flyd.map(R.always("Thank you! Now redirecting..."), respOk$)
+  , flyd.map(resp => `There was an error: ${resp.body.error}`, respErr$)
+  ])
+
+  const notif = notification.init({message$, hideDelay: 20000})
+  flyd.map(resp => {setTourCookies(resp.body.nonprofit); window.location = '/'}, respOk$)
+
+
+  return {
+    openModal$
+  , currentStep$
+  , wiz
+  , orgForm
+  , contactForm
+  , infoForm
+  , loading$
+  , notif
+  }
+}
+
+// const trackGA = () => {
+//   if(!ga) return
+//   ga('send', {
+//     hitType: 'event',
+//     eventCategory: 'ClickSignUp',
+//     eventAction: 'click',
+//     eventLabel: location.pathname
+//   })
+// }
+
+
+const setTourCookies = nonprofit => {
+  document.cookie = `tour_dashboard=${nonprofit.id};path=/`
+  document.cookie = `tour_campaign=${nonprofit.id};path=/`
+  document.cookie = `tour_event=${nonprofit.id};path=/`
+  document.cookie = `tour_profile=${nonprofit.id};path=/`
+  document.cookie = `tour_transactions=${nonprofit.id};path=/`
+  document.cookie = `tour_supporters=${nonprofit.id};path=/`
+  document.cookie = `tour_subscribers=${nonprofit.id};path=/`
+}
+
+const postData = (orgForm, infoForm) => contactFormData => {
+  const send = {
+    nonprofit: orgForm.validData$()
+  , extraInfo: infoForm.validData$()
+  , user: contactFormData
+  }
+  return request({
+    method: 'post'
+  , path: '/nonprofits/onboard'
+  , send
+  }).load
+}
+
+const constraints = {
+  org: {
+    name: {required: true}
+  , city: {required: true}
+  , state_code: {required: true}
+  , zip_code: {required: true}
+  }
+, contact: {
+    email: {required: true, email: true}
+  , name: {required: true}
+  , phone: {required: true}
+  , password: {required: true, minLength: 7}
+  , password_confirmation: {required: true, matchesField: 'password'}
+  }
+, info: {}
+}
+
+const view = state => {
+  return h('div', [
+    modal({
+      show$: state.openModal$
+    , body: onboardWizard(state)
+    , title: 'Get started'
+    })
+  , notification.view(state.notif)
+  ])
+}
+
+const onboardWizard = state => {
+  const labels = [ 'Org', 'Info', 'Contact' ]
+  const steps = [ orgForm(state) , infoForm(state), contactForm(state) ]
+  return h('div', [ 
+    wizard.labels(state.wiz, labels)
+  , wizard.content(state.wiz, steps)
+  ])
+}
+
+const pricingDetails = h('div.u-marginTop--15.u-padding--10.u-background--fog', [
+  h('p', [
+    "CommitChange uses " 
+  , h('a.strong', {props: {href: 'https://www.stripe.com/', target :'_blank'}}, 'Stripe')
+  , ' to process transactions. Stripe takes a '
+  , h('strong', `${ENV.feeRate}% + ${ENV.perTransaction}¢`) 
+  , ' processing fee on every transaction.'])
+, h('p', [
+    'In order to support operations, feature development, and community building, '
+  , 'CommitChange takes an additional fee of ' 
+  , h('strong', `${ENV.platformFeeRate}%.`) 
+  ])
+, h('p.u-marginBottom--0', [
+  "Our fee scales down as your transaction volume scales up. "
+, h('a.strong', {props: {href: 'mailto:support@commitchange.com'}}, 'Contact us')
+, " to chat about volume discounts."
+  ])
+])
+
+const orgForm = state => {
+  const form = validatedForm.form(state.orgForm)
+  const field = fieldWithError(state.orgForm)
+  return h('div', [
+    form(h('form', [
+     h('fieldset', [
+        h('label', 'Organization Name')
+      , field(h('input', {props: {type: 'text', name: 'name', placeholder: ''}}))
+      ])
+    , h('fieldset', [
+        h('label', 'Website URL')
+      , field(h('input', {props: {type: 'text', name: 'website', placeholder: 'https://your-website.org'}}))
+      ])
+    , h('div.clearfix', [
+        h('fieldset.col-left-6.u-paddingRight--10', [
+          h('label', 'Org Email (public)')
+        , field(h('input', {props: {type: 'email', name: 'email', placeholder: 'example@name.org'}}))
+        ])
+      , h('fieldset.col-left-6', [
+          h('label', 'Org Phone (public)')
+        , field(h('input', {props: {type: 'text', name: 'phone', placeholder: '(XXX) XXX-XXXX'}}))
+        ])
+      ])
+    , h('div.clearfix', [
+        h('fieldset.col-left-6.u-paddingRight--10', [
+          h('label', 'City')
+        , field(h('input', {props: {type: 'text', name: 'city', placeholder: ''}}))
+        ])
+      , h('fieldset.col-left-3.u-paddingRight--10', [
+          h('label', 'State')
+        , field(h('input', {props: {type: 'text', name: 'state_code', placeholder: 'NY'}}))
+        ])
+      , h('fieldset.col-left-3', [
+          h('label', 'Zip Code')
+        , field(h('input', {props: {type: 'text', name: 'zip_code', placeholder: ''}}))
+        ])
+      ])
+    , h('div', [
+        h('button.button', 'Next')
+      ])
+    ]))
+  ])
+}
+
+const infoForm = state => {
+  const form = validatedForm.form(state.infoForm)
+  const field = fieldWithError(state.infoForm)
+
+  return h('div', [
+    form(h('form', [
+      h('div.u-marginBottom--20', [
+        h('fieldset', [
+          h('label', {props: {htmlFor: 'registered-npo-checkbox'}}, 'What kind of entity are you fundraising for?')
+        ])
+      , h('fieldset', [
+          h('input', {props: {type: 'radio', name: 'entity_type', value: 'nonprofit', id: 'onboard-entity-nonprofit'}})
+        , h('label', {props: {htmlFor: 'onboard-entity-nonprofit'}}, 'A registered nonprofit')
+        ])
+      , h('fieldset', [
+          h('input', {props: {type: 'radio', name: 'entity_type', value: 'forprofit', id: 'onboard-entity-forprofit'}})
+        , h('label', {props: {htmlFor: 'onboard-entity-forprofit'}}, 'A for-profit company')
+        ])
+      , h('fieldset', [
+          h('input', {props: {type: 'radio', name: 'entity_type', value: 'unregistered', id: 'onboard-entity-unregistered'}})
+        , h('label', {props: {htmlFor: 'onboard-entity-unregistered'}}, 'An unregistered project, group, club, or other cause')
+        ])
+      ])
+    , h('div.u-marginBottom--20', [
+        h('fieldset', [
+          h('label', 'How do you want to use CommitChange?')
+        ])
+      , h('fieldset', [
+          h('input', {props: {type: 'checkbox', name: 'use_donations', id: 'onboard-use-donations'}})
+        , h('label', {props: {htmlFor: 'onboard-use-donations'}}, 'Donation processing')
+        ])
+      , h('fieldset', [
+          h('input', {props: {type: 'checkbox', name: 'use_crm', id: 'onboard-use-crm'}})
+        , h('label', {props: {htmlFor: 'onboard-use-crm'}}, 'Supporter relationship management')
+        ])
+      , h('fieldset', [
+          h('input', {props: {type: 'checkbox', name: 'use_campaigns', id: 'onboard-use-campaigns'}})
+        , h('label', {props: {htmlFor: 'onboard-use-campaigns'}}, 'Campaign fundriasing')
+        ])
+      , h('fieldset', [
+          h('input', {props: {type: 'checkbox', name: 'use_events', id: 'onboard-use-events'}})
+        , h('label', {props: {htmlFor: 'onboard-use-events'}}, 'Event pages and ticketing')
+        ])
+      ])
+    , h('fieldset', [
+        h('label', 'How did you hear about CommitChange?')
+      , field(h('input', {props: {type: 'text', name: 'how_they_heard', placeholder: 'Google, radio, referral, etc'}}))
+      ])
+    , h('button.button', 'Next')
+    ]))
+  ])
+}
+
+const contactForm = state => {
+  const form = validatedForm.form(state.contactForm)
+  const field = fieldWithError(state.contactForm)
+  return h('div', [
+    form(h('form', [
+      h('div.clearfix', [
+        h('fieldset.col-left-6.u-paddingRight--10', [
+          h('label', 'Your Name')
+        , field(h('input', {props: {type: 'text', name: 'name', placeholder: 'Full Name'}}))
+        ])
+      , h('fieldset.col-left-6', [
+          h('label', 'Your Email (used for login)')
+        , field(h('input', {props: {type: 'email', name: 'email', placeholder: 'youremail@example.com'}}))
+        ])
+      ])
+    , h('fieldset', [
+        h('label', 'New Password')
+      , field(h('input', {props: {type: 'password', name: 'password', placeholder: ''}}))
+      ])
+    , h('fieldset', [
+        h('label', 'Retype Password')
+      , field(h('input', {props: {type: 'password', name: 'password_confirmation', placeholder: ''}}))
+      ])
+    , h('fieldset', [
+        h('label', ['Your Phone', h('small', ' (for account recovery)')])
+      , field(h('input', {props: {type: 'text', name: 'phone', placeholder: '(XXX) XXX-XXXX'}}))
+      ])
+    , h('button.button', {props: {disabled: state.loading$()}}, 'Save & Finish')
+    ]))
+  ])
+}
+
+const container = document.querySelector("#ff-render-onboard")
+render(view, init(), container)
+
diff --git a/app/javascript/legacy/common/panels_layout.js b/app/javascript/legacy/common/panels_layout.js
new file mode 100644
index 00000000..6da6a7e5
--- /dev/null
+++ b/app/javascript/legacy/common/panels_layout.js
@@ -0,0 +1,71 @@
+// License: LGPL-3.0-or-later
+var $panelsLayout = $('.panelsLayout'),
+	$panelsLayoutBody = $panelsLayout.find('.panelsLayout-body'),
+	$sidePanel = $panelsLayoutBody.find('.sidePanel'),
+	$mainPanel = $panelsLayoutBody.find('.mainPanel'),
+	filterButton = document.getElementById('button--openFilter'),
+	$tableMeta = $('.table-meta--main'),
+	win = window
+
+function setPanelsLayoutBodyHeight(){
+	var bodyOffsetTop = $panelsLayoutBody.offset().top
+	var winInnerHeight = win.innerHeight
+	var calculatedHeight = (winInnerHeight - bodyOffsetTop) + 'px'
+
+	if($('.filterPanel').length)
+		$('.filterPanel, .sidePanel, .mainPanel').css('height', calculatedHeight)
+	else
+		$('.sidePanel, .mainPanel').css('height', calculatedHeight)
+}
+
+setPanelsLayoutBodyHeight()
+$(win).resize(setPanelsLayoutBodyHeight)
+
+appl.def('open_side_panel', function(){
+	appl.def('is_showing_side_panel', true)
+	$panelsLayout.removeClass('is-showingFilterPanel')
+	$sidePanel.scrollTop(0)
+	$panelsLayout.addClass('is-showingSidePanel')
+	setPanelsLayoutBodyHeight()
+		$mainPanel.css({
+		left: '0px',
+		right: 'initial'
+	})
+	if (filterButton)
+		filterButton.removeAttribute('data-selected')
+	return appl
+})
+
+appl.def('close_side_panel', function(){
+	appl.def('is_showing_side_panel', false)
+	$mainPanel.find('tr').removeAttr('data-selected')
+	$panelsLayout.removeClass('is-showingSidePanel')
+	setPanelsLayoutBodyHeight()
+	window.history.pushState({},'index', win.location.pathname)
+	return appl
+})
+
+appl.def('open_filter_panel', function(){
+	$panelsLayout.removeClass('is-showingSidePanel')
+	$panelsLayout.addClass('is-showingFilterPanel')
+	$mainPanel.find('tr').removeAttr('data-selected')
+	$mainPanel.css({
+		right: '0px',
+		left: 'initial'
+	})
+	filterButton.setAttribute('data-selected', '')
+	window.history.pushState({},'index', win.location.pathname)
+	return appl
+})
+
+appl.def('close_filter_panel', function(){
+	$panelsLayout.removeClass('is-showingFilterPanel')
+	filterButton.removeAttribute('data-selected')
+	return appl
+})
+
+appl.def('scroll_main_panel', function(){
+	var main_panel = document.querySelector('.mainPanel')
+	main_panel.scrollTop = main_panel.scrollHeight
+})
+
diff --git a/app/javascript/legacy/common/pikaday-timepicker.js b/app/javascript/legacy/common/pikaday-timepicker.js
new file mode 100644
index 00000000..0e2cc62e
--- /dev/null
+++ b/app/javascript/legacy/common/pikaday-timepicker.js
@@ -0,0 +1,30 @@
+// License: LGPL-3.0-or-later
+const bind = require('attr-binder')
+const Pikaday = require('pikaday-time')
+const moment = require('moment')
+
+bind('pikaday-timepicker', function(container, format) {
+  const button = container.querySelector('a')
+  const input = container.querySelector('input')
+  input.readOnly = true
+
+  const maxDate_str = input.getAttribute('pikaday-maxDate')
+  const maxDate = maxDate_str ? moment(maxDate_str) : undefined
+  const defaultDate_str = input.getAttribute('pikaday-defaultDate')
+  const defaultDate = defaultDate_str ? moment(defaultDate_str) : undefined
+  new Pikaday({
+    showTime: true
+  , showMinutes: true
+  , showSeconds: false
+  , autoClose: false
+  , timeLabel: 'Time'
+  , format
+  , setDefaultDate: Boolean(defaultDate)
+  , field: input
+  , maxDate
+  , defaultDate
+  , trigger: button
+  })
+
+})
+
diff --git a/app/javascript/legacy/common/polyfills.js b/app/javascript/legacy/common/polyfills.js
new file mode 100644
index 00000000..9504f84e
--- /dev/null
+++ b/app/javascript/legacy/common/polyfills.js
@@ -0,0 +1,11 @@
+// License: LGPL-3.0-or-later
+// Console fallback
+if (!window.console) {
+	window.console = new function() {
+		this.log = function(str) {}
+		this.dir = function(str) {}
+	}
+}
+
+// Promises polyfill
+require('es6-promise').polyfill()
diff --git a/app/javascript/legacy/common/post-form-data.es6 b/app/javascript/legacy/common/post-form-data.es6
new file mode 100644
index 00000000..6046b517
--- /dev/null
+++ b/app/javascript/legacy/common/post-form-data.es6
@@ -0,0 +1,26 @@
+// License: LGPL-3.0-or-later
+const flyd = require('flyd')
+const R = require('ramda')
+
+// Using the bare-bones XMLHttpRequest API so we can post form data and upload the image
+// Returns a flyd stream
+
+module.exports = R.curryN(2, (url, object) => {
+  var stream = flyd.stream()
+  var req = new XMLHttpRequest()
+  var formData = new FormData()
+  R.mapObjIndexed((val, key) => {
+    if(val.constructor === Object) val = JSON.stringify(val)
+    formData.append(key, val)
+  }, object)
+  req.open("POST", url)
+  // req.setRequestHeader('X-CSRF-Token', window._csrf)
+  req.send(formData)
+  req.onload = ev => {
+    var body = {}
+    try { body = JSON.parse(req.response) } catch(e) { }
+    stream( {status: req.status, body: body } )
+  }
+  return stream
+})
+
diff --git a/app/javascript/legacy/common/post-form-data.js b/app/javascript/legacy/common/post-form-data.js
new file mode 100644
index 00000000..3a95ce4c
--- /dev/null
+++ b/app/javascript/legacy/common/post-form-data.js
@@ -0,0 +1,27 @@
+// License: LGPL-3.0-or-later
+const flyd = require('flyd')
+const R = require('ramda')
+
+// TODO make this use flyd-ajax
+// Using the bare-bones XMLHttpRequest API so we can post form data and upload the image
+// Returns a flyd stream
+
+module.exports = R.curryN(2, (url, object) => {
+  var stream = flyd.stream()
+  var req = new XMLHttpRequest()
+  var formData = new FormData()
+  R.mapObjIndexed((val, key) => {
+    if(val.constructor === Object) val = JSON.stringify(val)
+    formData.append(key, val)
+  }, object)
+  req.open("POST", url)
+  // req.setRequestHeader('X-CSRF-Token', window._csrf)
+  req.send(formData)
+  req.onload = ev => {
+    var body = {}
+    try { body = JSON.parse(req.response) } catch(e) { }
+    stream( {status: req.status, body: body } )
+  }
+  return stream
+})
+
diff --git a/app/javascript/legacy/common/request.js b/app/javascript/legacy/common/request.js
new file mode 100644
index 00000000..ebd508ca
--- /dev/null
+++ b/app/javascript/legacy/common/request.js
@@ -0,0 +1,11 @@
+// License: LGPL-3.0-or-later
+const R = require('ramda')
+const request = require('flyd-ajax')
+
+module.exports = options => {
+  options.headers = R.merge({
+    'Content-Type': 'application/json'
+  , 'X-CSRF-Token': window._csrf
+  }, options.headers || {})
+  return request(options)
+}
diff --git a/app/javascript/legacy/common/restful_resource.js b/app/javascript/legacy/common/restful_resource.js
new file mode 100644
index 00000000..89029c3a
--- /dev/null
+++ b/app/javascript/legacy/common/restful_resource.js
@@ -0,0 +1,139 @@
+// License: LGPL-3.0-or-later
+/* A simple module for dealing with ajax-based resources in viewscript
+	*
+	*
+	* Define a 'resource object' in appl that has these properties
+	*   resource_name: 'donations' (plural name that matches the model)
+	*   path_prefix: '/' (optional, defaults to empty string, or relative path)
+	*   query: object of parameters to use for indexing (eg search queries)
+	*   after_action: function callback run after the request (where action is fetch, index, etc)
+	*   after_action_failure: callback for failed requests (where action is fetch, index, etc)
+	*
+	* Call the ajax functions like this:
+	* in js:
+	*   appl.ajax.index(appl.resource_object)
+	*   appl.ajax.create(appl.donations, {amount: 420})
+	* in viewscript in the dom:
+	*   ajax.index resource_object
+	*   ajax.create donations form_object
+	*/
+
+var request = require('../common/client')
+
+var restful_resource = {}
+module.exports = restful_resource
+
+
+appl.def('ajax', {
+	index: function(prop, node) {
+		var resource = appl.vs(prop) || {}
+		var name = resource.resource_name || prop
+		var path = resource.path_prefix || ''
+		before_request(prop)
+		return new Promise(function(resolve, reject) {
+			request.get(path + name).query(resource.query)
+				.end(function(err, resp) {
+					var tmp = resource.data
+					after_request(prop, err, resp)
+					if(resp.ok) {
+						if(resource.query && resource.query.page > 1 && resource.concat_data) {
+							appl.def(prop + '.data', tmp.concat(resp.body.data))
+						}
+						resolve(resp)
+					} else {
+						reject(resp)
+					}
+				})
+		})
+	},
+
+	fetch: function(prop, id, node) {
+		var resource = appl.vs(prop) || {}
+		var name = resource.resource_name || prop
+		var path = resource.path_prefix || ''
+		before_request(prop)
+		return new Promise(function(resolve, reject) {
+			request.get(path + name + '/' + id).query(resource.query)
+				.end(function(err, resp) {
+					after_request(prop, err, resp)
+					if(resp.ok) resolve(resp)
+					else reject(resp)
+				})
+		})
+	},
+
+	create: function(prop, form_obj, node) {
+		var resource = appl.vs(prop) || {}
+		var name = resource.resource_name || prop
+		var path = resource.path_prefix || ''
+		before_request(prop)
+		return new Promise(function(resolve, reject) {
+			request.post(path + name).send(nested_obj(name, form_obj))
+				.end(function(err, resp) {
+					after_request(prop, err, resp)
+					if(resp.ok) resolve(resp)
+					else reject(resp)
+				})
+		})
+	},
+
+	update: function(prop, id, form_obj, node) {
+		var resource = appl.vs(prop) || {}
+		var name = resource.resource_name || prop
+		var path = resource.path_prefix || ''
+		before_request(prop)
+		return new Promise(function(resolve, reject) {
+			request.put(path + name + '/' + id).send(nested_obj(name, form_obj))
+			.end(function(err, resp) {
+				after_request(prop, err, resp)
+				if(resp.ok) resolve(resp)
+				else reject(resp)
+			})
+		})
+	},
+
+	del: function(prop, id, node) {
+		var resource = appl.vs(prop) || {}
+		var path = (resource.path_prefix || '') + (resource.resource_name || prop)
+		before_request(prop)
+		return new Promise(function(resolve, reject) {
+			request.del(path + '/' + id)
+				.end(function(err, resp) {
+					after_request(prop, err, resp)
+					if(resp.ok) resolve(resp)
+					else reject(resp)
+				})
+		})
+	}
+})
+
+
+// Given a viewscript property, set some state before every request.
+// Eg. appl.ajax.index('donations') will cause appl.donations.loading to be
+// true before the request finishes
+function before_request(prop) {
+	appl.def(prop + '.loading', true)
+	appl.def(prop + '.error', '')
+}
+
+
+// Set some data after each request.
+function after_request(prop, err, resp) {
+	appl.def(prop + '.loading', false)
+	if(resp.ok) {
+		appl.def(prop, resp.body)
+	} else {
+		appl.def(prop + '.error', resp.body)
+	}
+}
+
+
+// Simply return an object nested under 'name'
+// Will singularize the given name if plural
+// eg: given 'donations' and {amount: 111}, return {donation: {amount: 111}}
+function nested_obj(name, child_obj) {
+	var parent_obj = {}
+	parent_obj[appl.to_singular(name)] = child_obj
+	return parent_obj
+}
+
diff --git a/app/javascript/legacy/common/sanitize-slug.js b/app/javascript/legacy/common/sanitize-slug.js
new file mode 100644
index 00000000..e2cfcf4c
--- /dev/null
+++ b/app/javascript/legacy/common/sanitize-slug.js
@@ -0,0 +1,5 @@
+// License: LGPL-3.0-or-later
+module.exports = str =>
+ str.trim().toLowerCase()
+  .replace(/\s*[^A-Za-z0-9\-]\s*/g, '-') // Replace any oddballs with a hyphen
+  .replace(/-+$/g,'').replace(/^-+/, '').replace(/-+/, '-') // Remove starting/trailing and repeated hyphens
diff --git a/app/javascript/legacy/common/scroll_toggle_class.js b/app/javascript/legacy/common/scroll_toggle_class.js
new file mode 100644
index 00000000..7ed8a119
--- /dev/null
+++ b/app/javascript/legacy/common/scroll_toggle_class.js
@@ -0,0 +1,32 @@
+// License: LGPL-3.0-or-later
+module.exports = function(el, className, parentClass) {
+	var $el = $(el)
+	var elPxFromTop = $el.offset().top
+	var $parent = $el.parents(parentClass).length ? $el.parents(parentClass) : $el.parent()
+
+	var parentHeightPlusTop =  $parent.height() + $parent.offset().top - $el.height()
+	var $elToToggle
+
+	if (parentClass === undefined) {
+		parentClass = ''
+		$elToToggle = $el
+	} else {
+		$elToToggle = $parent
+	}
+
+
+	// the parentClass param is optional but if it is passed
+	// then the className is applied to it instead of the el
+
+	$(window).scroll(function() {
+		var scrollPosition = $(window).scrollTop()
+
+		if(scrollPosition >= elPxFromTop)
+			$elToToggle.addClass(className)
+		else
+			$elToToggle.removeClass(className)
+
+		if(parentClass && scrollPosition >= parentHeightPlusTop)
+			$elToToggle.removeClass(className)
+	})
+}
diff --git a/app/javascript/legacy/common/search-data.js b/app/javascript/legacy/common/search-data.js
new file mode 100644
index 00000000..1dcb383e
--- /dev/null
+++ b/app/javascript/legacy/common/search-data.js
@@ -0,0 +1,52 @@
+// License: LGPL-3.0-or-later
+const R = require('ramda')
+const h = require('flimflam/h')
+const flyd = require('flimflam/flyd')
+const getValidData = require('../common/get-valid-data')
+
+const getCurry = path => query => getValidData(path, query)
+
+module.exports = (path, pageLength) => {
+  const get = getCurry(path)
+  const searchLessQuery$ = flyd.stream()
+
+  const submitSearch$ = flyd.stream()
+  const searchQuery$ = flyd.map(searchQuery(pageLength), submitSearch$)
+
+  const searchLessResults$ = flyd.flatMap(q => get(q), searchLessQuery$)
+  const searchResults$ = flyd.flatMap(q => get(q), searchQuery$)
+
+  const allResults$ = flyd.merge(searchLessResults$, searchResults$)
+
+  const hasMoreResults$ = flyd.map(x => x && x.length >= pageLength, allResults$)
+
+  const data$ = flyd.scanMerge([
+    [searchLessResults$, (data, results) => R.concat(data, results)]
+  , [searchResults$, (data, results) => results]
+  ], [])
+
+  searchLessQuery$({page: 1, page_length: pageLength, search: ''})
+
+  const loading$ = flyd.mergeAll([
+    flyd.map(R.always(true), submitSearch$)
+  , flyd.map(R.always(true), searchLessQuery$)
+  , flyd.map(R.always(false), allResults$)
+  , flyd.stream(true)
+  ])
+
+  return {
+    data$
+  , searchLessQuery$
+  , loading$
+  , pageLength
+  , hasMoreResults$
+  , submitSearch$
+  }
+}
+
+const searchQuery = pageLength => ev => {
+  ev.preventDefault()
+  const search = ev.target.querySelector('input').value
+  return {page: 1, search, page_length: pageLength}
+}
+
diff --git a/app/javascript/legacy/common/super-agent-frp.js b/app/javascript/legacy/common/super-agent-frp.js
new file mode 100644
index 00000000..82968f83
--- /dev/null
+++ b/app/javascript/legacy/common/super-agent-frp.js
@@ -0,0 +1,33 @@
+// License: LGPL-3.0-or-later
+// super-agent with default json and csrf wrappers
+// Also has a FRP api (using flyd) rather than the default '.end'
+// Every call to .perform() returns a flyd stream
+
+var request = require('superagent')
+var flyd = require("flyd")
+
+var wrapper = {
+  post: function() {
+    return injectFlyd(request.post.apply(this, arguments).set('X-CSRF-Token', window._csrf).type('json'))
+  }
+, put: function() {
+    return injectFlyd(request.put.apply(this, arguments).set('X-CSRF-Token', window._csrf).type('json'))
+  }
+, del: function() {
+    return injectFlyd(request.del.apply(this, arguments).set('X-CSRF-Token', window._csrf).type('json'))
+  }
+, get: function() {
+    return injectFlyd(request.get.apply(this, arguments).accept('json'))
+  }
+}
+
+function injectFlyd(req) {
+	req.perform = function() {
+		var $stream = flyd.stream()
+		req.end(function(err, resp) { $stream(resp) })
+		return $stream
+	}
+	return req
+}
+
+module.exports = wrapper
diff --git a/app/javascript/legacy/common/super-agent-promise.js b/app/javascript/legacy/common/super-agent-promise.js
new file mode 100644
index 00000000..12905091
--- /dev/null
+++ b/app/javascript/legacy/common/super-agent-promise.js
@@ -0,0 +1,41 @@
+// License: LGPL-3.0-or-later
+// super-agent with default json and csrf wrappers
+// Also has a Promise api ('.then' and '.catch') rather than the default '.end'
+
+var request = require('superagent')
+
+var wrapper = {}
+module.exports = wrapper
+
+wrapper.post = function() {
+	var req = request.post.apply(this, arguments).set('X-CSRF-Token', window._csrf).type('json')
+	return convert_to_promise(req)
+}
+
+wrapper.put = function() {
+	var req = request.put.apply(this, arguments).set('X-CSRF-Token', window._csrf).type('json')
+	return convert_to_promise(req)
+}
+
+wrapper.del = function() {
+	var req = request.del.apply(this, arguments).set('X-CSRF-Token', window._csrf).type('json')
+	return convert_to_promise(req)
+}
+
+wrapper.get = function(path) {
+	var req = request.get.call(this, path).accept('json')
+	return convert_to_promise(req)
+}
+
+function convert_to_promise(req) {
+	req.perform = function() {
+		return new Promise(function(resolve, reject) {
+			req.end(function(err, resp) {
+				if(resp && resp.ok) { resolve(resp) }
+				else { reject(resp) }
+			})
+		})
+	}
+	return req
+}
+
diff --git a/app/javascript/legacy/common/time-remaining.js b/app/javascript/legacy/common/time-remaining.js
new file mode 100644
index 00000000..8a435720
--- /dev/null
+++ b/app/javascript/legacy/common/time-remaining.js
@@ -0,0 +1,39 @@
+// License: LGPL-3.0-or-later
+const flyd = require('flyd')
+const flyd_every = require('flyd/module/every')
+const moment = require('moment-timezone')
+const format = require('../common/format')
+const pluralize = format.pluralize
+
+// Given an end dateTime ("2015-11-17 19:00") and a time-zone ("America/Los_Angeles"),
+// if the end dateTime has passed, return false
+// if the end dateTime is more than a day away
+//   then return the number of days away
+// if the end dateTime is less than a day away
+//   then return a countdown stream with seconds precision
+//
+// This function returns a stream.
+//
+// This function takes a timezone in the format "Country/City"
+// See here: http://momentjs.com/timezone/
+//
+
+
+const timeRemaining = (endDateTime, tz) => {
+  if(!endDateTime) return flyd.stream(false)
+  const format = "YYYY-MM-DD hh:mm:ss zz"
+  tz = tz || ENV.nonprofitTimezone || 'America/Los_Angeles'
+  const [now, end] = [moment().tz(tz), moment(endDateTime, format).tz(tz).seconds(59)]
+  console.log({now, end})
+  if(end.isBefore(now)) return flyd.stream(false)
+
+  if(end.diff(now, 'hours') <= 24) {
+    return flyd.map(
+      t => moment.utc(end.diff(moment(t))).format("HH:mm:ss")
+    , flyd_every(1000))
+  } else {
+    return flyd.stream(pluralize(end.diff(now, 'days'), 'days'))
+  }
+}
+
+module.exports = timeRemaining
diff --git a/app/javascript/legacy/common/utilities.js b/app/javascript/legacy/common/utilities.js
new file mode 100755
index 00000000..07fd38a9
--- /dev/null
+++ b/app/javascript/legacy/common/utilities.js
@@ -0,0 +1,180 @@
+// License: LGPL-3.0-or-later
+// Utilities!
+// XXX remove this whole file and split into modules with specific concerns
+const phoneFormatter = require('phone-formatter');
+const R = require('ramda')
+
+var utils = {}
+
+module.exports = utils
+
+// XXX remove
+utils.capitalize = string =>
+  string.charAt(0).toUpperCase() + string.slice(1)
+
+// Print a single message for Rails error responses
+// XXX remove
+utils.print_error = function (response) {
+	var msg = 'Sorry! We encountered an error.'
+	if(!response) return msg
+	if(response.status === 500) return msg
+	else if(response.status === 404) return "404 - Not found"
+	else if(response.status === 422 || response.status === 401) {
+		if(!response.responseJSON) return msg
+
+		var json = response.responseJSON
+		if(json.length) return json[0]
+
+		else if(json.errors)
+			for (var key in json.errors)
+				return key + ' ' + json.errors[key][0]
+
+		else if(json.error) return json.error
+
+		else return msg
+	}
+}
+
+// Retrieve a URL parameter
+// XXX remove
+utils.get_param = function(name) {
+	var param = decodeURI((RegExp(name + '=' + '(.+?)(&|$)').exec(location.search) || [null])[1])
+	return (param == 'undefined') ? undefined : param
+}
+
+// XXX remove
+utils.change_url_param = function(key, value) {
+	if (!history || !history.replaceState) return
+	history.replaceState({}, "", utils.update_param(key, value))
+}
+
+// XXX remove. Depended on only by 'change_url_param' above
+utils.update_param = function(key, value, url) {
+	if(!url) url = window.location.href
+	var re = new RegExp("([?&])" + key + "=.*?(&|#|$)(.*)", "gi")
+
+	if(re.test(url)) {
+		if(typeof value !== 'undefined' && value !== null)
+			return url.replace(re, '$1' + key + "=" + value + '$2$3')
+		else {
+			var hash = url.split('#')
+			url = hash[0].replace(re, '$1$3').replace(/(&|\?)$/, '')
+			if(typeof hash[1] !== 'undefined' && hash[1] !== null)
+				url += '#' + hash[1]
+			return url
+		}
+	} else {
+		if (typeof value !== 'undefined' && value !== null) {
+			var separator = url.indexOf('?') !== -1 ? '&' : '?',
+				hash = url.split('#')
+			url = hash[0] + separator + key + '=' + value
+			if(typeof hash[1] !== 'undefined' && hash[1] !== null)
+				url += '#' + hash[1]
+			return url
+		}
+		else return url
+	}
+}
+
+// Pad a number with leading zeros
+// XXX remove
+utils.zero_pad = function(num, size) {
+	var str = num + ""
+	while (str.length < size) str = "0" + str
+	return str
+}
+
+// for doing an action after the user pauses for a second after an event
+// XXX remove
+utils.delay = (function() {
+	var timer = 0
+	return function(ms, callback) {
+		clearTimeout(timer)
+		timer = setTimeout(callback, ms)
+	}
+})()
+
+utils.number_with_commas = function(n) {
+	return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
+}
+
+// Merge x's properties with y (mutating)
+utils.merge = function(x, y) {
+	for (var key in y) { x[key] = y[key]; }
+	return x
+}
+
+var format = require('./format')
+utils.dollars_to_cents = format.dollarsToCents
+utils.cents_to_dollars = format.centsToDollars
+
+// Create a single FormData object from any number of inputs and forms (not bound to a single form)
+// Kind of a re-implementation of: http://www.w3.org/html/wg/drafts/html/master/forms.html#constructing-the-form-data-set
+// XXX remove
+utils.toFormData = function(form_el) {
+	var form_data = new FormData()
+	$(form_el).find('input, select, textarea').each(function(index) {
+		if(!this.name) return
+		if(this.files && this.files[0])
+			form_data.append(this.name, this.files[0])
+		else if(this.getAttribute("type") === "checkbox")
+			form_data.append(this.name, this.checked)
+		else if(this.value)
+			form_data.append(this.name, this.value)
+	})
+	return form_data
+}
+
+utils.mergeFormData = function(formData, obj) {
+	for(var key in obj) formData.append(key, obj[key])
+	return formData
+}
+
+
+// Given an array of values, return an array only with unique values
+// XXX remove
+utils.uniq = function(arr) {
+	var obj = {}
+	var index
+	var len = arr.length
+	var result = [];
+	for(index = 0; index < len; index += 1) obj[arr[index]] = arr[index];
+	for(index in obj) result.push(obj[index]);
+	return result
+}
+
+// XXX remove
+utils.address_with_commas = function(street, city, state){
+	var address = [street, city, state]
+	var pretty_print_add = []
+	for(var i = 0; i < address.length; i += 1) {
+		if (address[i] !== '' && address[i] != null) pretty_print_add.push(address[i])
+	}
+	return pretty_print_add.join(', ')
+}
+
+utils.pretty_phone = function(phone){
+	if(!phone) {return false}
+  
+  // first remove any non-digit characters globally 
+  // and get length of phone number
+  var clean = String(phone).replace(/\D/g, '')
+  var len = clean.length
+
+  var format = "(NNN) NNN-NNNN"
+
+  // then format based on length
+  if(len === 10) {
+    return phoneFormatter.format(clean, format) 
+  }
+  if(len > 10) {
+    var first = clean.substring(0, len - 10)
+    var last10 = clean.substring(len - 10) 
+    return `+${first} ${phoneFormatter.format(last10, format)}`
+  }
+
+  // if number is less than 10, don't apply any formatting
+  // and just return it
+  return clean
+}
+
diff --git a/app/javascript/legacy/common/vendor/Chart.min.js b/app/javascript/legacy/common/vendor/Chart.min.js
new file mode 100755
index 00000000..27b08032
--- /dev/null
+++ b/app/javascript/legacy/common/vendor/Chart.min.js
@@ -0,0 +1,3476 @@
+// License: LGPL-3.0-or-later
+/*!
+ * Chart.js
+ * http://chartjs.org/
+ * Version: 1.0.2
+ *
+ * Copyright 2015 Nick Downie
+ * Released under the MIT license
+ * https://github.com/nnnick/Chart.js/blob/master/LICENSE.md
+ */
+
+
+(function(){
+
+	"use strict";
+
+	//Declare root variable - window in the browser, global on the server
+	var root = this;
+	var previous = root.Chart;
+
+	//Occupy the global variable of Chart, and create a simple base class
+	var Chart = function(context){
+		var chart = this;
+		this.canvas = context.canvas;
+
+		this.ctx = context;
+
+		//Variables global to the chart
+		var computeDimension = function(element,dimension)
+		{
+			if (element['offset'+dimension])
+			{
+				return element['offset'+dimension];
+			}
+			else
+			{
+				return document.defaultView.getComputedStyle(element).getPropertyValue(dimension);
+			}
+		}
+
+		var width = this.width = computeDimension(context.canvas,'Width');
+		var height = this.height = computeDimension(context.canvas,'Height');
+
+		// Firefox requires this to work correctly
+		context.canvas.width  = width;
+		context.canvas.height = height;
+
+		var width = this.width = context.canvas.width;
+		var height = this.height = context.canvas.height;
+		this.aspectRatio = this.width / this.height;
+		//High pixel density displays - multiply the size of the canvas height/width by the device pixel ratio, then scale.
+		helpers.retinaScale(this);
+
+		return this;
+	};
+	//Globally expose the defaults to allow for user updating/changing
+	Chart.defaults = {
+		global: {
+			// Boolean - Whether to animate the chart
+			animation: true,
+
+			// Number - Number of animation steps
+			animationSteps: 60,
+
+			// String - Animation easing effect
+			animationEasing: "easeOutQuart",
+
+			// Boolean - If we should show the scale at all
+			showScale: true,
+
+			// Boolean - If we want to override with a hard coded scale
+			scaleOverride: false,
+
+			// ** Required if scaleOverride is true **
+			// Number - The number of steps in a hard coded scale
+			scaleSteps: null,
+			// Number - The value jump in the hard coded scale
+			scaleStepWidth: null,
+			// Number - The scale starting value
+			scaleStartValue: null,
+
+			// String - Colour of the scale line
+			scaleLineColor: "rgba(0,0,0,.1)",
+
+			// Number - Pixel width of the scale line
+			scaleLineWidth: 1,
+
+			// Boolean - Whether to show labels on the scale
+			scaleShowLabels: true,
+
+			// Interpolated JS string - can access value
+			scaleLabel: "<%=value%>",
+
+			// Boolean - Whether the scale should stick to integers, and not show any floats even if drawing space is there
+			scaleIntegersOnly: true,
+
+			// Boolean - Whether the scale should start at zero, or an order of magnitude down from the lowest value
+			scaleBeginAtZero: false,
+
+			// String - Scale label font declaration for the scale label
+			scaleFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
+
+			// Number - Scale label font size in pixels
+			scaleFontSize: 12,
+
+			// String - Scale label font weight style
+			scaleFontStyle: "normal",
+
+			// String - Scale label font colour
+			scaleFontColor: "#666",
+
+			// Boolean - whether or not the chart should be responsive and resize when the browser does.
+			responsive: false,
+
+			// Boolean - whether to maintain the starting aspect ratio or not when responsive, if set to false, will take up entire container
+			maintainAspectRatio: true,
+
+			// Boolean - Determines whether to draw tooltips on the canvas or not - attaches events to touchmove & mousemove
+			showTooltips: true,
+
+			// Boolean - Determines whether to draw built-in tooltip or call custom tooltip function
+			customTooltips: false,
+
+			// Array - Array of string names to attach tooltip events
+			tooltipEvents: ["mousemove", "touchstart", "touchmove", "mouseout"],
+
+			// String - Tooltip background colour
+			tooltipFillColor: "rgba(0,0,0,0.8)",
+
+			// String - Tooltip label font declaration for the scale label
+			tooltipFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
+
+			// Number - Tooltip label font size in pixels
+			tooltipFontSize: 14,
+
+			// String - Tooltip font weight style
+			tooltipFontStyle: "normal",
+
+			// String - Tooltip label font colour
+			tooltipFontColor: "#fff",
+
+			// String - Tooltip title font declaration for the scale label
+			tooltipTitleFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
+
+			// Number - Tooltip title font size in pixels
+			tooltipTitleFontSize: 14,
+
+			// String - Tooltip title font weight style
+			tooltipTitleFontStyle: "bold",
+
+			// String - Tooltip title font colour
+			tooltipTitleFontColor: "#fff",
+
+			// Number - pixel width of padding around tooltip text
+			tooltipYPadding: 6,
+
+			// Number - pixel width of padding around tooltip text
+			tooltipXPadding: 6,
+
+			// Number - Size of the caret on the tooltip
+			tooltipCaretSize: 8,
+
+			// Number - Pixel radius of the tooltip border
+			tooltipCornerRadius: 6,
+
+			// Number - Pixel offset from point x to tooltip edge
+			tooltipXOffset: 10,
+
+			// String - Template string for single tooltips
+			tooltipTemplate: "<%if (label){%><%=label%>: <%}%><%= value %>",
+
+			// String - Template string for single tooltips
+			multiTooltipTemplate: "<%= value %>",
+
+			// String - Colour behind the legend colour block
+			multiTooltipKeyBackground: '#fff',
+
+			// Function - Will fire on animation progression.
+			onAnimationProgress: function(){},
+
+			// Function - Will fire on animation completion.
+			onAnimationComplete: function(){}
+
+		}
+	};
+
+	//Create a dictionary of chart types, to allow for extension of existing types
+	Chart.types = {};
+
+	//Global Chart helpers object for utility methods and classes
+	var helpers = Chart.helpers = {};
+
+		//-- Basic js utility methods
+	var each = helpers.each = function(loopable,callback,self){
+			var additionalArgs = Array.prototype.slice.call(arguments, 3);
+			// Check to see if null or undefined firstly.
+			if (loopable){
+				if (loopable.length === +loopable.length){
+					var i;
+					for (i=0; i= 0; i--) {
+				var currentItem = arrayToSearch[i];
+				if (filterCallback(currentItem)){
+					return currentItem;
+				}
+			}
+		},
+		inherits = helpers.inherits = function(extensions){
+			//Basic javascript inheritance based on the model created in Backbone.js
+			var parent = this;
+			var ChartElement = (extensions && extensions.hasOwnProperty("constructor")) ? extensions.constructor : function(){ return parent.apply(this, arguments); };
+
+			var Surrogate = function(){ this.constructor = ChartElement;};
+			Surrogate.prototype = parent.prototype;
+			ChartElement.prototype = new Surrogate();
+
+			ChartElement.extend = inherits;
+
+			if (extensions) extend(ChartElement.prototype, extensions);
+
+			ChartElement.__super__ = parent.prototype;
+
+			return ChartElement;
+		},
+		noop = helpers.noop = function(){},
+		uid = helpers.uid = (function(){
+			var id=0;
+			return function(){
+				return "chart-" + id++;
+			};
+		})(),
+		warn = helpers.warn = function(str){
+			//Method for warning of errors
+			if (window.console && typeof window.console.warn == "function") console.warn(str);
+		},
+		amd = helpers.amd = (typeof define == 'function' && define.amd),
+		//-- Math methods
+		isNumber = helpers.isNumber = function(n){
+			return !isNaN(parseFloat(n)) && isFinite(n);
+		},
+		max = helpers.max = function(array){
+			return Math.max.apply( Math, array );
+		},
+		min = helpers.min = function(array){
+			return Math.min.apply( Math, array );
+		},
+		cap = helpers.cap = function(valueToCap,maxValue,minValue){
+			if(isNumber(maxValue)) {
+				if( valueToCap > maxValue ) {
+					return maxValue;
+				}
+			}
+			else if(isNumber(minValue)){
+				if ( valueToCap < minValue ){
+					return minValue;
+				}
+			}
+			return valueToCap;
+		},
+		getDecimalPlaces = helpers.getDecimalPlaces = function(num){
+			if (num%1!==0 && isNumber(num)){
+				return num.toString().split(".")[1].length;
+			}
+			else {
+				return 0;
+			}
+		},
+		toRadians = helpers.radians = function(degrees){
+			return degrees * (Math.PI/180);
+		},
+		// Gets the angle from vertical upright to the point about a centre.
+		getAngleFromPoint = helpers.getAngleFromPoint = function(centrePoint, anglePoint){
+			var distanceFromXCenter = anglePoint.x - centrePoint.x,
+				distanceFromYCenter = anglePoint.y - centrePoint.y,
+				radialDistanceFromCenter = Math.sqrt( distanceFromXCenter * distanceFromXCenter + distanceFromYCenter * distanceFromYCenter);
+
+
+			var angle = Math.PI * 2 + Math.atan2(distanceFromYCenter, distanceFromXCenter);
+
+			//If the segment is in the top left quadrant, we need to add another rotation to the angle
+			if (distanceFromXCenter < 0 && distanceFromYCenter < 0){
+				angle += Math.PI*2;
+			}
+
+			return {
+				angle: angle,
+				distance: radialDistanceFromCenter
+			};
+		},
+		aliasPixel = helpers.aliasPixel = function(pixelWidth){
+			return (pixelWidth % 2 === 0) ? 0 : 0.5;
+		},
+		splineCurve = helpers.splineCurve = function(FirstPoint,MiddlePoint,AfterPoint,t){
+			//Props to Rob Spencer at scaled innovation for his post on splining between points
+			//http://scaledinnovation.com/analytics/splines/aboutSplines.html
+			var d01=Math.sqrt(Math.pow(MiddlePoint.x-FirstPoint.x,2)+Math.pow(MiddlePoint.y-FirstPoint.y,2)),
+				d12=Math.sqrt(Math.pow(AfterPoint.x-MiddlePoint.x,2)+Math.pow(AfterPoint.y-MiddlePoint.y,2)),
+				fa=t*d01/(d01+d12),// scaling factor for triangle Ta
+				fb=t*d12/(d01+d12);
+			return {
+				inner : {
+					x : MiddlePoint.x-fa*(AfterPoint.x-FirstPoint.x),
+					y : MiddlePoint.y-fa*(AfterPoint.y-FirstPoint.y)
+				},
+				outer : {
+					x: MiddlePoint.x+fb*(AfterPoint.x-FirstPoint.x),
+					y : MiddlePoint.y+fb*(AfterPoint.y-FirstPoint.y)
+				}
+			};
+		},
+		calculateOrderOfMagnitude = helpers.calculateOrderOfMagnitude = function(val){
+			return Math.floor(Math.log(val) / Math.LN10);
+		},
+		calculateScaleRange = helpers.calculateScaleRange = function(valuesArray, drawingSize, textSize, startFromZero, integersOnly){
+
+			//Set a minimum step of two - a point at the top of the graph, and a point at the base
+			var minSteps = 2,
+				maxSteps = Math.floor(drawingSize/(textSize * 1.5)),
+				skipFitting = (minSteps >= maxSteps);
+
+			var maxValue = max(valuesArray),
+				minValue = min(valuesArray);
+
+			// We need some degree of seperation here to calculate the scales if all the values are the same
+			// Adding/minusing 0.5 will give us a range of 1.
+			if (maxValue === minValue){
+				maxValue += 0.5;
+				// So we don't end up with a graph with a negative start value if we've said always start from zero
+				if (minValue >= 0.5 && !startFromZero){
+					minValue -= 0.5;
+				}
+				else{
+					// Make up a whole number above the values
+					maxValue += 0.5;
+				}
+			}
+
+			var	valueRange = Math.abs(maxValue - minValue),
+				rangeOrderOfMagnitude = calculateOrderOfMagnitude(valueRange),
+				graphMax = Math.ceil(maxValue / (1 * Math.pow(10, rangeOrderOfMagnitude))) * Math.pow(10, rangeOrderOfMagnitude),
+				graphMin = (startFromZero) ? 0 : Math.floor(minValue / (1 * Math.pow(10, rangeOrderOfMagnitude))) * Math.pow(10, rangeOrderOfMagnitude),
+				graphRange = graphMax - graphMin,
+				stepValue = Math.pow(10, rangeOrderOfMagnitude),
+				numberOfSteps = Math.round(graphRange / stepValue);
+
+			//If we have more space on the graph we'll use it to give more definition to the data
+			while((numberOfSteps > maxSteps || (numberOfSteps * 2) < maxSteps) && !skipFitting) {
+				if(numberOfSteps > maxSteps){
+					stepValue *=2;
+					numberOfSteps = Math.round(graphRange/stepValue);
+					// Don't ever deal with a decimal number of steps - cancel fitting and just use the minimum number of steps.
+					if (numberOfSteps % 1 !== 0){
+						skipFitting = true;
+					}
+				}
+				//We can fit in double the amount of scale points on the scale
+				else{
+					//If user has declared ints only, and the step value isn't a decimal
+					if (integersOnly && rangeOrderOfMagnitude >= 0){
+						//If the user has said integers only, we need to check that making the scale more granular wouldn't make it a float
+						if(stepValue/2 % 1 === 0){
+							stepValue /=2;
+							numberOfSteps = Math.round(graphRange/stepValue);
+						}
+						//If it would make it a float break out of the loop
+						else{
+							break;
+						}
+					}
+					//If the scale doesn't have to be an int, make the scale more granular anyway.
+					else{
+						stepValue /=2;
+						numberOfSteps = Math.round(graphRange/stepValue);
+					}
+
+				}
+			}
+
+			if (skipFitting){
+				numberOfSteps = minSteps;
+				stepValue = graphRange / numberOfSteps;
+			}
+
+			return {
+				steps : numberOfSteps,
+				stepValue : stepValue,
+				min : graphMin,
+				max	: graphMin + (numberOfSteps * stepValue)
+			};
+
+		},
+		/* jshint ignore:start */
+		// Blows up jshint errors based on the new Function constructor
+		//Templating methods
+		//Javascript micro templating by John Resig - source at http://ejohn.org/blog/javascript-micro-templating/
+		template = helpers.template = function(templateString, valuesObject){
+
+			// If templateString is function rather than string-template - call the function for valuesObject
+
+			if(templateString instanceof Function){
+			 	return templateString(valuesObject);
+		 	}
+
+			var cache = {};
+			function tmpl(str, data){
+				// Figure out if we're getting a template, or if we need to
+				// load the template - and be sure to cache the result.
+				var fn = !/\W/.test(str) ?
+				cache[str] = cache[str] :
+
+				// Generate a reusable function that will serve as a template
+				// generator (and which will be cached).
+				new Function("obj",
+					"var p=[],print=function(){p.push.apply(p,arguments);};" +
+
+					// Introduce the data as local variables using with(){}
+					"with(obj){p.push('" +
+
+					// Convert the template into pure JavaScript
+					str
+						.replace(/[\r\t\n]/g, " ")
+						.split("<%").join("\t")
+						.replace(/((^|%>)[^\t]*)'/g, "$1\r")
+						.replace(/\t=(.*?)%>/g, "',$1,'")
+						.split("\t").join("');")
+						.split("%>").join("p.push('")
+						.split("\r").join("\\'") +
+					"');}return p.join('');"
+				);
+
+				// Provide some basic currying to the user
+				return data ? fn( data ) : fn;
+			}
+			return tmpl(templateString,valuesObject);
+		},
+		/* jshint ignore:end */
+		generateLabels = helpers.generateLabels = function(templateString,numberOfSteps,graphMin,stepValue){
+			var labelsArray = new Array(numberOfSteps);
+			if (labelTemplateString){
+				each(labelsArray,function(val,index){
+					labelsArray[index] = template(templateString,{value: (graphMin + (stepValue*(index+1)))});
+				});
+			}
+			return labelsArray;
+		},
+		//--Animation methods
+		//Easing functions adapted from Robert Penner's easing equations
+		//http://www.robertpenner.com/easing/
+		easingEffects = helpers.easingEffects = {
+			linear: function (t) {
+				return t;
+			},
+			easeInQuad: function (t) {
+				return t * t;
+			},
+			easeOutQuad: function (t) {
+				return -1 * t * (t - 2);
+			},
+			easeInOutQuad: function (t) {
+				if ((t /= 1 / 2) < 1) return 1 / 2 * t * t;
+				return -1 / 2 * ((--t) * (t - 2) - 1);
+			},
+			easeInCubic: function (t) {
+				return t * t * t;
+			},
+			easeOutCubic: function (t) {
+				return 1 * ((t = t / 1 - 1) * t * t + 1);
+			},
+			easeInOutCubic: function (t) {
+				if ((t /= 1 / 2) < 1) return 1 / 2 * t * t * t;
+				return 1 / 2 * ((t -= 2) * t * t + 2);
+			},
+			easeInQuart: function (t) {
+				return t * t * t * t;
+			},
+			easeOutQuart: function (t) {
+				return -1 * ((t = t / 1 - 1) * t * t * t - 1);
+			},
+			easeInOutQuart: function (t) {
+				if ((t /= 1 / 2) < 1) return 1 / 2 * t * t * t * t;
+				return -1 / 2 * ((t -= 2) * t * t * t - 2);
+			},
+			easeInQuint: function (t) {
+				return 1 * (t /= 1) * t * t * t * t;
+			},
+			easeOutQuint: function (t) {
+				return 1 * ((t = t / 1 - 1) * t * t * t * t + 1);
+			},
+			easeInOutQuint: function (t) {
+				if ((t /= 1 / 2) < 1) return 1 / 2 * t * t * t * t * t;
+				return 1 / 2 * ((t -= 2) * t * t * t * t + 2);
+			},
+			easeInSine: function (t) {
+				return -1 * Math.cos(t / 1 * (Math.PI / 2)) + 1;
+			},
+			easeOutSine: function (t) {
+				return 1 * Math.sin(t / 1 * (Math.PI / 2));
+			},
+			easeInOutSine: function (t) {
+				return -1 / 2 * (Math.cos(Math.PI * t / 1) - 1);
+			},
+			easeInExpo: function (t) {
+				return (t === 0) ? 1 : 1 * Math.pow(2, 10 * (t / 1 - 1));
+			},
+			easeOutExpo: function (t) {
+				return (t === 1) ? 1 : 1 * (-Math.pow(2, -10 * t / 1) + 1);
+			},
+			easeInOutExpo: function (t) {
+				if (t === 0) return 0;
+				if (t === 1) return 1;
+				if ((t /= 1 / 2) < 1) return 1 / 2 * Math.pow(2, 10 * (t - 1));
+				return 1 / 2 * (-Math.pow(2, -10 * --t) + 2);
+			},
+			easeInCirc: function (t) {
+				if (t >= 1) return t;
+				return -1 * (Math.sqrt(1 - (t /= 1) * t) - 1);
+			},
+			easeOutCirc: function (t) {
+				return 1 * Math.sqrt(1 - (t = t / 1 - 1) * t);
+			},
+			easeInOutCirc: function (t) {
+				if ((t /= 1 / 2) < 1) return -1 / 2 * (Math.sqrt(1 - t * t) - 1);
+				return 1 / 2 * (Math.sqrt(1 - (t -= 2) * t) + 1);
+			},
+			easeInElastic: function (t) {
+				var s = 1.70158;
+				var p = 0;
+				var a = 1;
+				if (t === 0) return 0;
+				if ((t /= 1) == 1) return 1;
+				if (!p) p = 1 * 0.3;
+				if (a < Math.abs(1)) {
+					a = 1;
+					s = p / 4;
+				} else s = p / (2 * Math.PI) * Math.asin(1 / a);
+				return -(a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t * 1 - s) * (2 * Math.PI) / p));
+			},
+			easeOutElastic: function (t) {
+				var s = 1.70158;
+				var p = 0;
+				var a = 1;
+				if (t === 0) return 0;
+				if ((t /= 1) == 1) return 1;
+				if (!p) p = 1 * 0.3;
+				if (a < Math.abs(1)) {
+					a = 1;
+					s = p / 4;
+				} else s = p / (2 * Math.PI) * Math.asin(1 / a);
+				return a * Math.pow(2, -10 * t) * Math.sin((t * 1 - s) * (2 * Math.PI) / p) + 1;
+			},
+			easeInOutElastic: function (t) {
+				var s = 1.70158;
+				var p = 0;
+				var a = 1;
+				if (t === 0) return 0;
+				if ((t /= 1 / 2) == 2) return 1;
+				if (!p) p = 1 * (0.3 * 1.5);
+				if (a < Math.abs(1)) {
+					a = 1;
+					s = p / 4;
+				} else s = p / (2 * Math.PI) * Math.asin(1 / a);
+				if (t < 1) return -0.5 * (a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t * 1 - s) * (2 * Math.PI) / p));
+				return a * Math.pow(2, -10 * (t -= 1)) * Math.sin((t * 1 - s) * (2 * Math.PI) / p) * 0.5 + 1;
+			},
+			easeInBack: function (t) {
+				var s = 1.70158;
+				return 1 * (t /= 1) * t * ((s + 1) * t - s);
+			},
+			easeOutBack: function (t) {
+				var s = 1.70158;
+				return 1 * ((t = t / 1 - 1) * t * ((s + 1) * t + s) + 1);
+			},
+			easeInOutBack: function (t) {
+				var s = 1.70158;
+				if ((t /= 1 / 2) < 1) return 1 / 2 * (t * t * (((s *= (1.525)) + 1) * t - s));
+				return 1 / 2 * ((t -= 2) * t * (((s *= (1.525)) + 1) * t + s) + 2);
+			},
+			easeInBounce: function (t) {
+				return 1 - easingEffects.easeOutBounce(1 - t);
+			},
+			easeOutBounce: function (t) {
+				if ((t /= 1) < (1 / 2.75)) {
+					return 1 * (7.5625 * t * t);
+				} else if (t < (2 / 2.75)) {
+					return 1 * (7.5625 * (t -= (1.5 / 2.75)) * t + 0.75);
+				} else if (t < (2.5 / 2.75)) {
+					return 1 * (7.5625 * (t -= (2.25 / 2.75)) * t + 0.9375);
+				} else {
+					return 1 * (7.5625 * (t -= (2.625 / 2.75)) * t + 0.984375);
+				}
+			},
+			easeInOutBounce: function (t) {
+				if (t < 1 / 2) return easingEffects.easeInBounce(t * 2) * 0.5;
+				return easingEffects.easeOutBounce(t * 2 - 1) * 0.5 + 1 * 0.5;
+			}
+		},
+		//Request animation polyfill - http://www.paulirish.com/2011/requestanimationframe-for-smart-animating/
+		requestAnimFrame = helpers.requestAnimFrame = (function(){
+			return window.requestAnimationFrame ||
+				window.webkitRequestAnimationFrame ||
+				window.mozRequestAnimationFrame ||
+				window.oRequestAnimationFrame ||
+				window.msRequestAnimationFrame ||
+				function(callback) {
+					return window.setTimeout(callback, 1000 / 60);
+				};
+		})(),
+		cancelAnimFrame = helpers.cancelAnimFrame = (function(){
+			return window.cancelAnimationFrame ||
+				window.webkitCancelAnimationFrame ||
+				window.mozCancelAnimationFrame ||
+				window.oCancelAnimationFrame ||
+				window.msCancelAnimationFrame ||
+				function(callback) {
+					return window.clearTimeout(callback, 1000 / 60);
+				};
+		})(),
+		animationLoop = helpers.animationLoop = function(callback,totalSteps,easingString,onProgress,onComplete,chartInstance){
+
+			var currentStep = 0,
+				easingFunction = easingEffects[easingString] || easingEffects.linear;
+
+			var animationFrame = function(){
+				currentStep++;
+				var stepDecimal = currentStep/totalSteps;
+				var easeDecimal = easingFunction(stepDecimal);
+
+				callback.call(chartInstance,easeDecimal,stepDecimal, currentStep);
+				onProgress.call(chartInstance,easeDecimal,stepDecimal);
+				if (currentStep < totalSteps){
+					chartInstance.animationFrame = requestAnimFrame(animationFrame);
+				} else{
+					onComplete.apply(chartInstance);
+				}
+			};
+			requestAnimFrame(animationFrame);
+		},
+		//-- DOM methods
+		getRelativePosition = helpers.getRelativePosition = function(evt){
+			var mouseX, mouseY;
+			var e = evt.originalEvent || evt,
+				canvas = evt.currentTarget || evt.srcElement,
+				boundingRect = canvas.getBoundingClientRect();
+
+			if (e.touches){
+				mouseX = e.touches[0].clientX - boundingRect.left;
+				mouseY = e.touches[0].clientY - boundingRect.top;
+
+			}
+			else{
+				mouseX = e.clientX - boundingRect.left;
+				mouseY = e.clientY - boundingRect.top;
+			}
+
+			return {
+				x : mouseX,
+				y : mouseY
+			};
+
+		},
+		addEvent = helpers.addEvent = function(node,eventType,method){
+			if (node.addEventListener){
+				node.addEventListener(eventType,method);
+			} else if (node.attachEvent){
+				node.attachEvent("on"+eventType, method);
+			} else {
+				node["on"+eventType] = method;
+			}
+		},
+		removeEvent = helpers.removeEvent = function(node, eventType, handler){
+			if (node.removeEventListener){
+				node.removeEventListener(eventType, handler, false);
+			} else if (node.detachEvent){
+				node.detachEvent("on"+eventType,handler);
+			} else{
+				node["on" + eventType] = noop;
+			}
+		},
+		bindEvents = helpers.bindEvents = function(chartInstance, arrayOfEvents, handler){
+			// Create the events object if it's not already present
+			if (!chartInstance.events) chartInstance.events = {};
+
+			each(arrayOfEvents,function(eventName){
+				chartInstance.events[eventName] = function(){
+					handler.apply(chartInstance, arguments);
+				};
+				addEvent(chartInstance.chart.canvas,eventName,chartInstance.events[eventName]);
+			});
+		},
+		unbindEvents = helpers.unbindEvents = function (chartInstance, arrayOfEvents) {
+			each(arrayOfEvents, function(handler,eventName){
+				removeEvent(chartInstance.chart.canvas, eventName, handler);
+			});
+		},
+		getMaximumWidth = helpers.getMaximumWidth = function(domNode){
+			var container = domNode.parentNode;
+			// TODO = check cross browser stuff with this.
+			return container.clientWidth;
+		},
+		getMaximumHeight = helpers.getMaximumHeight = function(domNode){
+			var container = domNode.parentNode;
+			// TODO = check cross browser stuff with this.
+			return container.clientHeight;
+		},
+		getMaximumSize = helpers.getMaximumSize = helpers.getMaximumWidth, // legacy support
+		retinaScale = helpers.retinaScale = function(chart){
+			var ctx = chart.ctx,
+				width = chart.canvas.width,
+				height = chart.canvas.height;
+
+			if (window.devicePixelRatio) {
+				ctx.canvas.style.width = width + "px";
+				ctx.canvas.style.height = height + "px";
+				ctx.canvas.height = height * window.devicePixelRatio;
+				ctx.canvas.width = width * window.devicePixelRatio;
+				ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
+			}
+		},
+		//-- Canvas methods
+		clear = helpers.clear = function(chart){
+			chart.ctx.clearRect(0,0,chart.width,chart.height);
+		},
+		fontString = helpers.fontString = function(pixelSize,fontStyle,fontFamily){
+			return fontStyle + " " + pixelSize+"px " + fontFamily;
+		},
+		longestText = helpers.longestText = function(ctx,font,arrayOfStrings){
+			ctx.font = font;
+			var longest = 0;
+			each(arrayOfStrings,function(string){
+				var textWidth = ctx.measureText(string).width;
+				longest = (textWidth > longest) ? textWidth : longest;
+			});
+			return longest;
+		},
+		drawRoundedRectangle = helpers.drawRoundedRectangle = function(ctx,x,y,width,height,radius){
+			ctx.beginPath();
+			ctx.moveTo(x + radius, y);
+			ctx.lineTo(x + width - radius, y);
+			ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
+			ctx.lineTo(x + width, y + height - radius);
+			ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
+			ctx.lineTo(x + radius, y + height);
+			ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
+			ctx.lineTo(x, y + radius);
+			ctx.quadraticCurveTo(x, y, x + radius, y);
+			ctx.closePath();
+		};
+
+
+	//Store a reference to each instance - allowing us to globally resize chart instances on window resize.
+	//Destroy method on the chart will remove the instance of the chart from this reference.
+	Chart.instances = {};
+
+	Chart.Type = function(data,options,chart){
+		this.options = options;
+		this.chart = chart;
+		this.id = uid();
+		//Add the chart instance to the global namespace
+		Chart.instances[this.id] = this;
+
+		// Initialize is always called when a chart type is created
+		// By default it is a no op, but it should be extended
+		if (options.responsive){
+			this.resize();
+		}
+		this.initialize.call(this,data);
+	};
+
+	//Core methods that'll be a part of every chart type
+	extend(Chart.Type.prototype,{
+		initialize : function(){return this;},
+		clear : function(){
+			clear(this.chart);
+			return this;
+		},
+		stop : function(){
+			// Stops any current animation loop occuring
+			cancelAnimFrame(this.animationFrame);
+			return this;
+		},
+		resize : function(callback){
+			this.stop();
+			var canvas = this.chart.canvas,
+				newWidth = getMaximumWidth(this.chart.canvas),
+				newHeight = this.options.maintainAspectRatio ? newWidth / this.chart.aspectRatio : getMaximumHeight(this.chart.canvas);
+
+			canvas.width = this.chart.width = newWidth;
+			canvas.height = this.chart.height = newHeight;
+
+			retinaScale(this.chart);
+
+			if (typeof callback === "function"){
+				callback.apply(this, Array.prototype.slice.call(arguments, 1));
+			}
+			return this;
+		},
+		reflow : noop,
+		render : function(reflow){
+			if (reflow){
+				this.reflow();
+			}
+			if (this.options.animation && !reflow){
+				helpers.animationLoop(
+					this.draw,
+					this.options.animationSteps,
+					this.options.animationEasing,
+					this.options.onAnimationProgress,
+					this.options.onAnimationComplete,
+					this
+				);
+			}
+			else{
+				this.draw();
+				this.options.onAnimationComplete.call(this);
+			}
+			return this;
+		},
+		generateLegend : function(){
+			return template(this.options.legendTemplate,this);
+		},
+		destroy : function(){
+			this.clear();
+			unbindEvents(this, this.events);
+			var canvas = this.chart.canvas;
+
+			// Reset canvas height/width attributes starts a fresh with the canvas context
+			canvas.width = this.chart.width;
+			canvas.height = this.chart.height;
+
+			// < IE9 doesn't support removeProperty
+			if (canvas.style.removeProperty) {
+				canvas.style.removeProperty('width');
+				canvas.style.removeProperty('height');
+			} else {
+				canvas.style.removeAttribute('width');
+				canvas.style.removeAttribute('height');
+			}
+
+			delete Chart.instances[this.id];
+		},
+		showTooltip : function(ChartElements, forceRedraw){
+			// Only redraw the chart if we've actually changed what we're hovering on.
+			if (typeof this.activeElements === 'undefined') this.activeElements = [];
+
+			var isChanged = (function(Elements){
+				var changed = false;
+
+				if (Elements.length !== this.activeElements.length){
+					changed = true;
+					return changed;
+				}
+
+				each(Elements, function(element, index){
+					if (element !== this.activeElements[index]){
+						changed = true;
+					}
+				}, this);
+				return changed;
+			}).call(this, ChartElements);
+
+			if (!isChanged && !forceRedraw){
+				return;
+			}
+			else{
+				this.activeElements = ChartElements;
+			}
+			this.draw();
+			if(this.options.customTooltips){
+				this.options.customTooltips(false);
+			}
+			if (ChartElements.length > 0){
+				// If we have multiple datasets, show a MultiTooltip for all of the data points at that index
+				if (this.datasets && this.datasets.length > 1) {
+					var dataArray,
+						dataIndex;
+
+					for (var i = this.datasets.length - 1; i >= 0; i--) {
+						dataArray = this.datasets[i].points || this.datasets[i].bars || this.datasets[i].segments;
+						dataIndex = indexOf(dataArray, ChartElements[0]);
+						if (dataIndex !== -1){
+							break;
+						}
+					}
+					var tooltipLabels = [],
+						tooltipColors = [],
+						medianPosition = (function(index) {
+
+							// Get all the points at that particular index
+							var Elements = [],
+								dataCollection,
+								xPositions = [],
+								yPositions = [],
+								xMax,
+								yMax,
+								xMin,
+								yMin;
+							helpers.each(this.datasets, function(dataset){
+								dataCollection = dataset.points || dataset.bars || dataset.segments;
+								if (dataCollection[dataIndex] && dataCollection[dataIndex].hasValue()){
+									Elements.push(dataCollection[dataIndex]);
+								}
+							});
+
+							helpers.each(Elements, function(element) {
+								xPositions.push(element.x);
+								yPositions.push(element.y);
+
+
+								//Include any colour information about the element
+								tooltipLabels.push(helpers.template(this.options.multiTooltipTemplate, element));
+								tooltipColors.push({
+									fill: element._saved.fillColor || element.fillColor,
+									stroke: element._saved.strokeColor || element.strokeColor
+								});
+
+							}, this);
+
+							yMin = min(yPositions);
+							yMax = max(yPositions);
+
+							xMin = min(xPositions);
+							xMax = max(xPositions);
+
+							return {
+								x: (xMin > this.chart.width/2) ? xMin : xMax,
+								y: (yMin + yMax)/2
+							};
+						}).call(this, dataIndex);
+
+					new Chart.MultiTooltip({
+						x: medianPosition.x,
+						y: medianPosition.y,
+						xPadding: this.options.tooltipXPadding,
+						yPadding: this.options.tooltipYPadding,
+						xOffset: this.options.tooltipXOffset,
+						fillColor: this.options.tooltipFillColor,
+						textColor: this.options.tooltipFontColor,
+						fontFamily: this.options.tooltipFontFamily,
+						fontStyle: this.options.tooltipFontStyle,
+						fontSize: this.options.tooltipFontSize,
+						titleTextColor: this.options.tooltipTitleFontColor,
+						titleFontFamily: this.options.tooltipTitleFontFamily,
+						titleFontStyle: this.options.tooltipTitleFontStyle,
+						titleFontSize: this.options.tooltipTitleFontSize,
+						cornerRadius: this.options.tooltipCornerRadius,
+						labels: tooltipLabels,
+						legendColors: tooltipColors,
+						legendColorBackground : this.options.multiTooltipKeyBackground,
+						title: ChartElements[0].label,
+						chart: this.chart,
+						ctx: this.chart.ctx,
+						custom: this.options.customTooltips
+					}).draw();
+
+				} else {
+					each(ChartElements, function(Element) {
+						var tooltipPosition = Element.tooltipPosition();
+						new Chart.Tooltip({
+							x: Math.round(tooltipPosition.x),
+							y: Math.round(tooltipPosition.y),
+							xPadding: this.options.tooltipXPadding,
+							yPadding: this.options.tooltipYPadding,
+							fillColor: this.options.tooltipFillColor,
+							textColor: this.options.tooltipFontColor,
+							fontFamily: this.options.tooltipFontFamily,
+							fontStyle: this.options.tooltipFontStyle,
+							fontSize: this.options.tooltipFontSize,
+							caretHeight: this.options.tooltipCaretSize,
+							cornerRadius: this.options.tooltipCornerRadius,
+							text: template(this.options.tooltipTemplate, Element),
+							chart: this.chart,
+							custom: this.options.customTooltips
+						}).draw();
+					}, this);
+				}
+			}
+			return this;
+		},
+		toBase64Image : function(){
+			return this.chart.canvas.toDataURL.apply(this.chart.canvas, arguments);
+		}
+	});
+
+	Chart.Type.extend = function(extensions){
+
+		var parent = this;
+
+		var ChartType = function(){
+			return parent.apply(this,arguments);
+		};
+
+		//Copy the prototype object of the this class
+		ChartType.prototype = clone(parent.prototype);
+		//Now overwrite some of the properties in the base class with the new extensions
+		extend(ChartType.prototype, extensions);
+
+		ChartType.extend = Chart.Type.extend;
+
+		if (extensions.name || parent.prototype.name){
+
+			var chartName = extensions.name || parent.prototype.name;
+			//Assign any potential default values of the new chart type
+
+			//If none are defined, we'll use a clone of the chart type this is being extended from.
+			//I.e. if we extend a line chart, we'll use the defaults from the line chart if our new chart
+			//doesn't define some defaults of their own.
+
+			var baseDefaults = (Chart.defaults[parent.prototype.name]) ? clone(Chart.defaults[parent.prototype.name]) : {};
+
+			Chart.defaults[chartName] = extend(baseDefaults,extensions.defaults);
+
+			Chart.types[chartName] = ChartType;
+
+			//Register this new chart type in the Chart prototype
+			Chart.prototype[chartName] = function(data,options){
+				var config = merge(Chart.defaults.global, Chart.defaults[chartName], options || {});
+				return new ChartType(data,config,this);
+			};
+		} else{
+			warn("Name not provided for this chart, so it hasn't been registered");
+		}
+		return parent;
+	};
+
+	Chart.Element = function(configuration){
+		extend(this,configuration);
+		this.initialize.apply(this,arguments);
+		this.save();
+	};
+	extend(Chart.Element.prototype,{
+		initialize : function(){},
+		restore : function(props){
+			if (!props){
+				extend(this,this._saved);
+			} else {
+				each(props,function(key){
+					this[key] = this._saved[key];
+				},this);
+			}
+			return this;
+		},
+		save : function(){
+			this._saved = clone(this);
+			delete this._saved._saved;
+			return this;
+		},
+		update : function(newProps){
+			each(newProps,function(value,key){
+				this._saved[key] = this[key];
+				this[key] = value;
+			},this);
+			return this;
+		},
+		transition : function(props,ease){
+			each(props,function(value,key){
+				this[key] = ((value - this._saved[key]) * ease) + this._saved[key];
+			},this);
+			return this;
+		},
+		tooltipPosition : function(){
+			return {
+				x : this.x,
+				y : this.y
+			};
+		},
+		hasValue: function(){
+			return isNumber(this.value);
+		}
+	});
+
+	Chart.Element.extend = inherits;
+
+
+	Chart.Point = Chart.Element.extend({
+		display: true,
+		inRange: function(chartX,chartY){
+			var hitDetectionRange = this.hitDetectionRadius + this.radius;
+			return ((Math.pow(chartX-this.x, 2)+Math.pow(chartY-this.y, 2)) < Math.pow(hitDetectionRange,2));
+		},
+		draw : function(){
+			if (this.display){
+				var ctx = this.ctx;
+				ctx.beginPath();
+
+				ctx.arc(this.x, this.y, this.radius, 0, Math.PI*2);
+				ctx.closePath();
+
+				ctx.strokeStyle = this.strokeColor;
+				ctx.lineWidth = this.strokeWidth;
+
+				ctx.fillStyle = this.fillColor;
+
+				ctx.fill();
+				ctx.stroke();
+			}
+
+
+			//Quick debug for bezier curve splining
+			//Highlights control points and the line between them.
+			//Handy for dev - stripped in the min version.
+
+			// ctx.save();
+			// ctx.fillStyle = "black";
+			// ctx.strokeStyle = "black"
+			// ctx.beginPath();
+			// ctx.arc(this.controlPoints.inner.x,this.controlPoints.inner.y, 2, 0, Math.PI*2);
+			// ctx.fill();
+
+			// ctx.beginPath();
+			// ctx.arc(this.controlPoints.outer.x,this.controlPoints.outer.y, 2, 0, Math.PI*2);
+			// ctx.fill();
+
+			// ctx.moveTo(this.controlPoints.inner.x,this.controlPoints.inner.y);
+			// ctx.lineTo(this.x, this.y);
+			// ctx.lineTo(this.controlPoints.outer.x,this.controlPoints.outer.y);
+			// ctx.stroke();
+
+			// ctx.restore();
+
+
+
+		}
+	});
+
+	Chart.Arc = Chart.Element.extend({
+		inRange : function(chartX,chartY){
+
+			var pointRelativePosition = helpers.getAngleFromPoint(this, {
+				x: chartX,
+				y: chartY
+			});
+
+			//Check if within the range of the open/close angle
+			var betweenAngles = (pointRelativePosition.angle >= this.startAngle && pointRelativePosition.angle <= this.endAngle),
+				withinRadius = (pointRelativePosition.distance >= this.innerRadius && pointRelativePosition.distance <= this.outerRadius);
+
+			return (betweenAngles && withinRadius);
+			//Ensure within the outside of the arc centre, but inside arc outer
+		},
+		tooltipPosition : function(){
+			var centreAngle = this.startAngle + ((this.endAngle - this.startAngle) / 2),
+				rangeFromCentre = (this.outerRadius - this.innerRadius) / 2 + this.innerRadius;
+			return {
+				x : this.x + (Math.cos(centreAngle) * rangeFromCentre),
+				y : this.y + (Math.sin(centreAngle) * rangeFromCentre)
+			};
+		},
+		draw : function(animationPercent){
+
+			var easingDecimal = animationPercent || 1;
+
+			var ctx = this.ctx;
+
+			ctx.beginPath();
+
+			ctx.arc(this.x, this.y, this.outerRadius, this.startAngle, this.endAngle);
+
+			ctx.arc(this.x, this.y, this.innerRadius, this.endAngle, this.startAngle, true);
+
+			ctx.closePath();
+			ctx.strokeStyle = this.strokeColor;
+			ctx.lineWidth = this.strokeWidth;
+
+			ctx.fillStyle = this.fillColor;
+
+			ctx.fill();
+			ctx.lineJoin = 'bevel';
+
+			if (this.showStroke){
+				ctx.stroke();
+			}
+		}
+	});
+
+	Chart.Rectangle = Chart.Element.extend({
+		draw : function(){
+			var ctx = this.ctx,
+				halfWidth = this.width/2,
+				leftX = this.x - halfWidth,
+				rightX = this.x + halfWidth,
+				top = this.base - (this.base - this.y),
+				halfStroke = this.strokeWidth / 2;
+
+			// Canvas doesn't allow us to stroke inside the width so we can
+			// adjust the sizes to fit if we're setting a stroke on the line
+			if (this.showStroke){
+				leftX += halfStroke;
+				rightX -= halfStroke;
+				top += halfStroke;
+			}
+
+			ctx.beginPath();
+
+			ctx.fillStyle = this.fillColor;
+			ctx.strokeStyle = this.strokeColor;
+			ctx.lineWidth = this.strokeWidth;
+
+			// It'd be nice to keep this class totally generic to any rectangle
+			// and simply specify which border to miss out.
+			ctx.moveTo(leftX, this.base);
+			ctx.lineTo(leftX, top);
+			ctx.lineTo(rightX, top);
+			ctx.lineTo(rightX, this.base);
+			ctx.fill();
+			if (this.showStroke){
+				ctx.stroke();
+			}
+		},
+		height : function(){
+			return this.base - this.y;
+		},
+		inRange : function(chartX,chartY){
+			return (chartX >= this.x - this.width/2 && chartX <= this.x + this.width/2) && (chartY >= this.y && chartY <= this.base);
+		}
+	});
+
+	Chart.Tooltip = Chart.Element.extend({
+		draw : function(){
+
+			var ctx = this.chart.ctx;
+
+			ctx.font = fontString(this.fontSize,this.fontStyle,this.fontFamily);
+
+			this.xAlign = "center";
+			this.yAlign = "above";
+
+			//Distance between the actual element.y position and the start of the tooltip caret
+			var caretPadding = this.caretPadding = 2;
+
+			var tooltipWidth = ctx.measureText(this.text).width + 2*this.xPadding,
+				tooltipRectHeight = this.fontSize + 2*this.yPadding,
+				tooltipHeight = tooltipRectHeight + this.caretHeight + caretPadding;
+
+			if (this.x + tooltipWidth/2 >this.chart.width){
+				this.xAlign = "left";
+			} else if (this.x - tooltipWidth/2 < 0){
+				this.xAlign = "right";
+			}
+
+			if (this.y - tooltipHeight < 0){
+				this.yAlign = "below";
+			}
+
+
+			var tooltipX = this.x - tooltipWidth/2,
+				tooltipY = this.y - tooltipHeight;
+
+			ctx.fillStyle = this.fillColor;
+
+			// Custom Tooltips
+			if(this.custom){
+				this.custom(this);
+			}
+			else{
+				switch(this.yAlign)
+				{
+				case "above":
+					//Draw a caret above the x/y
+					ctx.beginPath();
+					ctx.moveTo(this.x,this.y - caretPadding);
+					ctx.lineTo(this.x + this.caretHeight, this.y - (caretPadding + this.caretHeight));
+					ctx.lineTo(this.x - this.caretHeight, this.y - (caretPadding + this.caretHeight));
+					ctx.closePath();
+					ctx.fill();
+					break;
+				case "below":
+					tooltipY = this.y + caretPadding + this.caretHeight;
+					//Draw a caret below the x/y
+					ctx.beginPath();
+					ctx.moveTo(this.x, this.y + caretPadding);
+					ctx.lineTo(this.x + this.caretHeight, this.y + caretPadding + this.caretHeight);
+					ctx.lineTo(this.x - this.caretHeight, this.y + caretPadding + this.caretHeight);
+					ctx.closePath();
+					ctx.fill();
+					break;
+				}
+
+				switch(this.xAlign)
+				{
+				case "left":
+					tooltipX = this.x - tooltipWidth + (this.cornerRadius + this.caretHeight);
+					break;
+				case "right":
+					tooltipX = this.x - (this.cornerRadius + this.caretHeight);
+					break;
+				}
+
+				drawRoundedRectangle(ctx,tooltipX,tooltipY,tooltipWidth,tooltipRectHeight,this.cornerRadius);
+
+				ctx.fill();
+
+				ctx.fillStyle = this.textColor;
+				ctx.textAlign = "center";
+				ctx.textBaseline = "middle";
+				ctx.fillText(this.text, tooltipX + tooltipWidth/2, tooltipY + tooltipRectHeight/2);
+			}
+		}
+	});
+
+	Chart.MultiTooltip = Chart.Element.extend({
+		initialize : function(){
+			this.font = fontString(this.fontSize,this.fontStyle,this.fontFamily);
+
+			this.titleFont = fontString(this.titleFontSize,this.titleFontStyle,this.titleFontFamily);
+
+			this.height = (this.labels.length * this.fontSize) + ((this.labels.length-1) * (this.fontSize/2)) + (this.yPadding*2) + this.titleFontSize *1.5;
+
+			this.ctx.font = this.titleFont;
+
+			var titleWidth = this.ctx.measureText(this.title).width,
+				//Label has a legend square as well so account for this.
+				labelWidth = longestText(this.ctx,this.font,this.labels) + this.fontSize + 3,
+				longestTextWidth = max([labelWidth,titleWidth]);
+
+			this.width = longestTextWidth + (this.xPadding*2);
+
+
+			var halfHeight = this.height/2;
+
+			//Check to ensure the height will fit on the canvas
+			if (this.y - halfHeight < 0 ){
+				this.y = halfHeight;
+			} else if (this.y + halfHeight > this.chart.height){
+				this.y = this.chart.height - halfHeight;
+			}
+
+			//Decide whether to align left or right based on position on canvas
+			if (this.x > this.chart.width/2){
+				this.x -= this.xOffset + this.width;
+			} else {
+				this.x += this.xOffset;
+			}
+
+
+		},
+		getLineHeight : function(index){
+			var baseLineHeight = this.y - (this.height/2) + this.yPadding,
+				afterTitleIndex = index-1;
+
+			//If the index is zero, we're getting the title
+			if (index === 0){
+				return baseLineHeight + this.titleFontSize/2;
+			} else{
+				return baseLineHeight + ((this.fontSize*1.5*afterTitleIndex) + this.fontSize/2) + this.titleFontSize * 1.5;
+			}
+
+		},
+		draw : function(){
+			// Custom Tooltips
+			if(this.custom){
+				this.custom(this);
+			}
+			else{
+				drawRoundedRectangle(this.ctx,this.x,this.y - this.height/2,this.width,this.height,this.cornerRadius);
+				var ctx = this.ctx;
+				ctx.fillStyle = this.fillColor;
+				ctx.fill();
+				ctx.closePath();
+
+				ctx.textAlign = "left";
+				ctx.textBaseline = "middle";
+				ctx.fillStyle = this.titleTextColor;
+				ctx.font = this.titleFont;
+
+				ctx.fillText(this.title,this.x + this.xPadding, this.getLineHeight(0));
+
+				ctx.font = this.font;
+				helpers.each(this.labels,function(label,index){
+					ctx.fillStyle = this.textColor;
+					ctx.fillText(label,this.x + this.xPadding + this.fontSize + 3, this.getLineHeight(index + 1));
+
+					//A bit gnarly, but clearing this rectangle breaks when using explorercanvas (clears whole canvas)
+					//ctx.clearRect(this.x + this.xPadding, this.getLineHeight(index + 1) - this.fontSize/2, this.fontSize, this.fontSize);
+					//Instead we'll make a white filled block to put the legendColour palette over.
+
+					ctx.fillStyle = this.legendColorBackground;
+					ctx.fillRect(this.x + this.xPadding, this.getLineHeight(index + 1) - this.fontSize/2, this.fontSize, this.fontSize);
+
+					ctx.fillStyle = this.legendColors[index].fill;
+					ctx.fillRect(this.x + this.xPadding, this.getLineHeight(index + 1) - this.fontSize/2, this.fontSize, this.fontSize);
+
+
+				},this);
+			}
+		}
+	});
+
+	Chart.Scale = Chart.Element.extend({
+		initialize : function(){
+			this.fit();
+		},
+		buildYLabels : function(){
+			this.yLabels = [];
+
+			var stepDecimalPlaces = getDecimalPlaces(this.stepValue);
+
+			for (var i=0; i<=this.steps; i++){
+				this.yLabels.push(template(this.templateString,{value:(this.min + (i * this.stepValue)).toFixed(stepDecimalPlaces)}));
+			}
+			this.yLabelWidth = (this.display && this.showLabels) ? longestText(this.ctx,this.font,this.yLabels) : 0;
+		},
+		addXLabel : function(label){
+			this.xLabels.push(label);
+			this.valuesCount++;
+			this.fit();
+		},
+		removeXLabel : function(){
+			this.xLabels.shift();
+			this.valuesCount--;
+			this.fit();
+		},
+		// Fitting loop to rotate x Labels and figure out what fits there, and also calculate how many Y steps to use
+		fit: function(){
+			// First we need the width of the yLabels, assuming the xLabels aren't rotated
+
+			// To do that we need the base line at the top and base of the chart, assuming there is no x label rotation
+			this.startPoint = (this.display) ? this.fontSize : 0;
+			this.endPoint = (this.display) ? this.height - (this.fontSize * 1.5) - 5 : this.height; // -5 to pad labels
+
+			// Apply padding settings to the start and end point.
+			this.startPoint += this.padding;
+			this.endPoint -= this.padding;
+
+			// Cache the starting height, so can determine if we need to recalculate the scale yAxis
+			var cachedHeight = this.endPoint - this.startPoint,
+				cachedYLabelWidth;
+
+			// Build the current yLabels so we have an idea of what size they'll be to start
+			/*
+			 *	This sets what is returned from calculateScaleRange as static properties of this class:
+			 *
+				this.steps;
+				this.stepValue;
+				this.min;
+				this.max;
+			 *
+			 */
+			this.calculateYRange(cachedHeight);
+
+			// With these properties set we can now build the array of yLabels
+			// and also the width of the largest yLabel
+			this.buildYLabels();
+
+			this.calculateXLabelRotation();
+
+			while((cachedHeight > this.endPoint - this.startPoint)){
+				cachedHeight = this.endPoint - this.startPoint;
+				cachedYLabelWidth = this.yLabelWidth;
+
+				this.calculateYRange(cachedHeight);
+				this.buildYLabels();
+
+				// Only go through the xLabel loop again if the yLabel width has changed
+				if (cachedYLabelWidth < this.yLabelWidth){
+					this.calculateXLabelRotation();
+				}
+			}
+
+		},
+		calculateXLabelRotation : function(){
+			//Get the width of each grid by calculating the difference
+			//between x offsets between 0 and 1.
+
+			this.ctx.font = this.font;
+
+			var firstWidth = this.ctx.measureText(this.xLabels[0]).width,
+				lastWidth = this.ctx.measureText(this.xLabels[this.xLabels.length - 1]).width,
+				firstRotated,
+				lastRotated;
+
+
+			this.xScalePaddingRight = lastWidth/2 + 3;
+			this.xScalePaddingLeft = (firstWidth/2 > this.yLabelWidth + 10) ? firstWidth/2 : this.yLabelWidth + 10;
+
+			this.xLabelRotation = 0;
+			if (this.display){
+				var originalLabelWidth = longestText(this.ctx,this.font,this.xLabels),
+					cosRotation,
+					firstRotatedWidth;
+				this.xLabelWidth = originalLabelWidth;
+				//Allow 3 pixels x2 padding either side for label readability
+				var xGridWidth = Math.floor(this.calculateX(1) - this.calculateX(0)) - 6;
+
+				//Max label rotate should be 90 - also act as a loop counter
+				while ((this.xLabelWidth > xGridWidth && this.xLabelRotation === 0) || (this.xLabelWidth > xGridWidth && this.xLabelRotation <= 90 && this.xLabelRotation > 0)){
+					cosRotation = Math.cos(toRadians(this.xLabelRotation));
+
+					firstRotated = cosRotation * firstWidth;
+					lastRotated = cosRotation * lastWidth;
+
+					// We're right aligning the text now.
+					if (firstRotated + this.fontSize / 2 > this.yLabelWidth + 8){
+						this.xScalePaddingLeft = firstRotated + this.fontSize / 2;
+					}
+					this.xScalePaddingRight = this.fontSize/2;
+
+
+					this.xLabelRotation++;
+					this.xLabelWidth = cosRotation * originalLabelWidth;
+
+				}
+				if (this.xLabelRotation > 0){
+					this.endPoint -= Math.sin(toRadians(this.xLabelRotation))*originalLabelWidth + 3;
+				}
+			}
+			else{
+				this.xLabelWidth = 0;
+				this.xScalePaddingRight = this.padding;
+				this.xScalePaddingLeft = this.padding;
+			}
+
+		},
+		// Needs to be overidden in each Chart type
+		// Otherwise we need to pass all the data into the scale class
+		calculateYRange: noop,
+		drawingArea: function(){
+			return this.startPoint - this.endPoint;
+		},
+		calculateY : function(value){
+			var scalingFactor = this.drawingArea() / (this.min - this.max);
+			return this.endPoint - (scalingFactor * (value - this.min));
+		},
+		calculateX : function(index){
+			var isRotated = (this.xLabelRotation > 0),
+				// innerWidth = (this.offsetGridLines) ? this.width - offsetLeft - this.padding : this.width - (offsetLeft + halfLabelWidth * 2) - this.padding,
+				innerWidth = this.width - (this.xScalePaddingLeft + this.xScalePaddingRight),
+				valueWidth = innerWidth/Math.max((this.valuesCount - ((this.offsetGridLines) ? 0 : 1)), 1),
+				valueOffset = (valueWidth * index) + this.xScalePaddingLeft;
+
+			if (this.offsetGridLines){
+				valueOffset += (valueWidth/2);
+			}
+
+			return Math.round(valueOffset);
+		},
+		update : function(newProps){
+			helpers.extend(this, newProps);
+			this.fit();
+		},
+		draw : function(){
+			var ctx = this.ctx,
+				yLabelGap = (this.endPoint - this.startPoint) / this.steps,
+				xStart = Math.round(this.xScalePaddingLeft);
+			if (this.display){
+				ctx.fillStyle = this.textColor;
+				ctx.font = this.font;
+				each(this.yLabels,function(labelString,index){
+					var yLabelCenter = this.endPoint - (yLabelGap * index),
+						linePositionY = Math.round(yLabelCenter),
+						drawHorizontalLine = this.showHorizontalLines;
+
+					ctx.textAlign = "right";
+					ctx.textBaseline = "middle";
+					if (this.showLabels){
+						ctx.fillText(labelString,xStart - 10,yLabelCenter);
+					}
+
+					// This is X axis, so draw it
+					if (index === 0 && !drawHorizontalLine){
+						drawHorizontalLine = true;
+					}
+
+					if (drawHorizontalLine){
+						ctx.beginPath();
+					}
+
+					if (index > 0){
+						// This is a grid line in the centre, so drop that
+						ctx.lineWidth = this.gridLineWidth;
+						ctx.strokeStyle = this.gridLineColor;
+					} else {
+						// This is the first line on the scale
+						ctx.lineWidth = this.lineWidth;
+						ctx.strokeStyle = this.lineColor;
+					}
+
+					linePositionY += helpers.aliasPixel(ctx.lineWidth);
+
+					if(drawHorizontalLine){
+						ctx.moveTo(xStart, linePositionY);
+						ctx.lineTo(this.width, linePositionY);
+						ctx.stroke();
+						ctx.closePath();
+					}
+
+					ctx.lineWidth = this.lineWidth;
+					ctx.strokeStyle = this.lineColor;
+					ctx.beginPath();
+					ctx.moveTo(xStart - 5, linePositionY);
+					ctx.lineTo(xStart, linePositionY);
+					ctx.stroke();
+					ctx.closePath();
+
+				},this);
+
+				each(this.xLabels,function(label,index){
+					var xPos = this.calculateX(index) + aliasPixel(this.lineWidth),
+						// Check to see if line/bar here and decide where to place the line
+						linePos = this.calculateX(index - (this.offsetGridLines ? 0.5 : 0)) + aliasPixel(this.lineWidth),
+						isRotated = (this.xLabelRotation > 0),
+						drawVerticalLine = this.showVerticalLines;
+
+					// This is Y axis, so draw it
+					if (index === 0 && !drawVerticalLine){
+						drawVerticalLine = true;
+					}
+
+					if (drawVerticalLine){
+						ctx.beginPath();
+					}
+
+					if (index > 0){
+						// This is a grid line in the centre, so drop that
+						ctx.lineWidth = this.gridLineWidth;
+						ctx.strokeStyle = this.gridLineColor;
+					} else {
+						// This is the first line on the scale
+						ctx.lineWidth = this.lineWidth;
+						ctx.strokeStyle = this.lineColor;
+					}
+
+					if (drawVerticalLine){
+						ctx.moveTo(linePos,this.endPoint);
+						ctx.lineTo(linePos,this.startPoint - 3);
+						ctx.stroke();
+						ctx.closePath();
+					}
+
+
+					ctx.lineWidth = this.lineWidth;
+					ctx.strokeStyle = this.lineColor;
+
+
+					// Small lines at the bottom of the base grid line
+					ctx.beginPath();
+					ctx.moveTo(linePos,this.endPoint);
+					ctx.lineTo(linePos,this.endPoint + 5);
+					ctx.stroke();
+					ctx.closePath();
+
+					ctx.save();
+					ctx.translate(xPos,(isRotated) ? this.endPoint + 12 : this.endPoint + 8);
+					ctx.rotate(toRadians(this.xLabelRotation)*-1);
+					ctx.font = this.font;
+					ctx.textAlign = (isRotated) ? "right" : "center";
+					ctx.textBaseline = (isRotated) ? "middle" : "top";
+					ctx.fillText(label, 0, 0);
+					ctx.restore();
+				},this);
+
+			}
+		}
+
+	});
+
+	Chart.RadialScale = Chart.Element.extend({
+		initialize: function(){
+			this.size = min([this.height, this.width]);
+			this.drawingArea = (this.display) ? (this.size/2) - (this.fontSize/2 + this.backdropPaddingY) : (this.size/2);
+		},
+		calculateCenterOffset: function(value){
+			// Take into account half font size + the yPadding of the top value
+			var scalingFactor = this.drawingArea / (this.max - this.min);
+
+			return (value - this.min) * scalingFactor;
+		},
+		update : function(){
+			if (!this.lineArc){
+				this.setScaleSize();
+			} else {
+				this.drawingArea = (this.display) ? (this.size/2) - (this.fontSize/2 + this.backdropPaddingY) : (this.size/2);
+			}
+			this.buildYLabels();
+		},
+		buildYLabels: function(){
+			this.yLabels = [];
+
+			var stepDecimalPlaces = getDecimalPlaces(this.stepValue);
+
+			for (var i=0; i<=this.steps; i++){
+				this.yLabels.push(template(this.templateString,{value:(this.min + (i * this.stepValue)).toFixed(stepDecimalPlaces)}));
+			}
+		},
+		getCircumference : function(){
+			return ((Math.PI*2) / this.valuesCount);
+		},
+		setScaleSize: function(){
+			/*
+			 * Right, this is really confusing and there is a lot of maths going on here
+			 * The gist of the problem is here: https://gist.github.com/nnnick/696cc9c55f4b0beb8fe9
+			 *
+			 * Reaction: https://dl.dropboxusercontent.com/u/34601363/toomuchscience.gif
+			 *
+			 * Solution:
+			 *
+			 * We assume the radius of the polygon is half the size of the canvas at first
+			 * at each index we check if the text overlaps.
+			 *
+			 * Where it does, we store that angle and that index.
+			 *
+			 * After finding the largest index and angle we calculate how much we need to remove
+			 * from the shape radius to move the point inwards by that x.
+			 *
+			 * We average the left and right distances to get the maximum shape radius that can fit in the box
+			 * along with labels.
+			 *
+			 * Once we have that, we can find the centre point for the chart, by taking the x text protrusion
+			 * on each side, removing that from the size, halving it and adding the left x protrusion width.
+			 *
+			 * This will mean we have a shape fitted to the canvas, as large as it can be with the labels
+			 * and position it in the most space efficient manner
+			 *
+			 * https://dl.dropboxusercontent.com/u/34601363/yeahscience.gif
+			 */
+
+
+			// Get maximum radius of the polygon. Either half the height (minus the text width) or half the width.
+			// Use this to calculate the offset + change. - Make sure L/R protrusion is at least 0 to stop issues with centre points
+			var largestPossibleRadius = min([(this.height/2 - this.pointLabelFontSize - 5), this.width/2]),
+				pointPosition,
+				i,
+				textWidth,
+				halfTextWidth,
+				furthestRight = this.width,
+				furthestRightIndex,
+				furthestRightAngle,
+				furthestLeft = 0,
+				furthestLeftIndex,
+				furthestLeftAngle,
+				xProtrusionLeft,
+				xProtrusionRight,
+				radiusReductionRight,
+				radiusReductionLeft,
+				maxWidthRadius;
+			this.ctx.font = fontString(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily);
+			for (i=0;i furthestRight) {
+						furthestRight = pointPosition.x + halfTextWidth;
+						furthestRightIndex = i;
+					}
+					if (pointPosition.x - halfTextWidth < furthestLeft) {
+						furthestLeft = pointPosition.x - halfTextWidth;
+						furthestLeftIndex = i;
+					}
+				}
+				else if (i < this.valuesCount/2) {
+					// Less than half the values means we'll left align the text
+					if (pointPosition.x + textWidth > furthestRight) {
+						furthestRight = pointPosition.x + textWidth;
+						furthestRightIndex = i;
+					}
+				}
+				else if (i > this.valuesCount/2){
+					// More than half the values means we'll right align the text
+					if (pointPosition.x - textWidth < furthestLeft) {
+						furthestLeft = pointPosition.x - textWidth;
+						furthestLeftIndex = i;
+					}
+				}
+			}
+
+			xProtrusionLeft = furthestLeft;
+
+			xProtrusionRight = Math.ceil(furthestRight - this.width);
+
+			furthestRightAngle = this.getIndexAngle(furthestRightIndex);
+
+			furthestLeftAngle = this.getIndexAngle(furthestLeftIndex);
+
+			radiusReductionRight = xProtrusionRight / Math.sin(furthestRightAngle + Math.PI/2);
+
+			radiusReductionLeft = xProtrusionLeft / Math.sin(furthestLeftAngle + Math.PI/2);
+
+			// Ensure we actually need to reduce the size of the chart
+			radiusReductionRight = (isNumber(radiusReductionRight)) ? radiusReductionRight : 0;
+			radiusReductionLeft = (isNumber(radiusReductionLeft)) ? radiusReductionLeft : 0;
+
+			this.drawingArea = largestPossibleRadius - (radiusReductionLeft + radiusReductionRight)/2;
+
+			//this.drawingArea = min([maxWidthRadius, (this.height - (2 * (this.pointLabelFontSize + 5)))/2])
+			this.setCenterPoint(radiusReductionLeft, radiusReductionRight);
+
+		},
+		setCenterPoint: function(leftMovement, rightMovement){
+
+			var maxRight = this.width - rightMovement - this.drawingArea,
+				maxLeft = leftMovement + this.drawingArea;
+
+			this.xCenter = (maxLeft + maxRight)/2;
+			// Always vertically in the centre as the text height doesn't change
+			this.yCenter = (this.height/2);
+		},
+
+		getIndexAngle : function(index){
+			var angleMultiplier = (Math.PI * 2) / this.valuesCount;
+			// Start from the top instead of right, so remove a quarter of the circle
+
+			return index * angleMultiplier - (Math.PI/2);
+		},
+		getPointPosition : function(index, distanceFromCenter){
+			var thisAngle = this.getIndexAngle(index);
+			return {
+				x : (Math.cos(thisAngle) * distanceFromCenter) + this.xCenter,
+				y : (Math.sin(thisAngle) * distanceFromCenter) + this.yCenter
+			};
+		},
+		draw: function(){
+			if (this.display){
+				var ctx = this.ctx;
+				each(this.yLabels, function(label, index){
+					// Don't draw a centre value
+					if (index > 0){
+						var yCenterOffset = index * (this.drawingArea/this.steps),
+							yHeight = this.yCenter - yCenterOffset,
+							pointPosition;
+
+						// Draw circular lines around the scale
+						if (this.lineWidth > 0){
+							ctx.strokeStyle = this.lineColor;
+							ctx.lineWidth = this.lineWidth;
+
+							if(this.lineArc){
+								ctx.beginPath();
+								ctx.arc(this.xCenter, this.yCenter, yCenterOffset, 0, Math.PI*2);
+								ctx.closePath();
+								ctx.stroke();
+							} else{
+								ctx.beginPath();
+								for (var i=0;i= 0; i--) {
+						if (this.angleLineWidth > 0){
+							var outerPosition = this.getPointPosition(i, this.calculateCenterOffset(this.max));
+							ctx.beginPath();
+							ctx.moveTo(this.xCenter, this.yCenter);
+							ctx.lineTo(outerPosition.x, outerPosition.y);
+							ctx.stroke();
+							ctx.closePath();
+						}
+						// Extra 3px out for some label spacing
+						var pointLabelPosition = this.getPointPosition(i, this.calculateCenterOffset(this.max) + 5);
+						ctx.font = fontString(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily);
+						ctx.fillStyle = this.pointLabelFontColor;
+
+						var labelsCount = this.labels.length,
+							halfLabelsCount = this.labels.length/2,
+							quarterLabelsCount = halfLabelsCount/2,
+							upperHalf = (i < quarterLabelsCount || i > labelsCount - quarterLabelsCount),
+							exactQuarter = (i === quarterLabelsCount || i === labelsCount - quarterLabelsCount);
+						if (i === 0){
+							ctx.textAlign = 'center';
+						} else if(i === halfLabelsCount){
+							ctx.textAlign = 'center';
+						} else if (i < halfLabelsCount){
+							ctx.textAlign = 'left';
+						} else {
+							ctx.textAlign = 'right';
+						}
+
+						// Set the correct text baseline based on outer positioning
+						if (exactQuarter){
+							ctx.textBaseline = 'middle';
+						} else if (upperHalf){
+							ctx.textBaseline = 'bottom';
+						} else {
+							ctx.textBaseline = 'top';
+						}
+
+						ctx.fillText(this.labels[i], pointLabelPosition.x, pointLabelPosition.y);
+					}
+				}
+			}
+		}
+	});
+
+	// Attach global event to resize each chart instance when the browser resizes
+	helpers.addEvent(window, "resize", (function(){
+		// Basic debounce of resize function so it doesn't hurt performance when resizing browser.
+		var timeout;
+		return function(){
+			clearTimeout(timeout);
+			timeout = setTimeout(function(){
+				each(Chart.instances,function(instance){
+					// If the responsive flag is set in the chart instance config
+					// Cascade the resize event down to the chart.
+					if (instance.options.responsive){
+						instance.resize(instance.render, true);
+					}
+				});
+			}, 50);
+		};
+	})());
+
+
+	if (amd) {
+		define(function(){
+			return Chart;
+		});
+	} else if (typeof module === 'object' && module.exports) {
+		module.exports = Chart;
+	}
+
+	root.Chart = Chart;
+
+	Chart.noConflict = function(){
+		root.Chart = previous;
+		return Chart;
+	};
+
+}).call(window);
+
+(function(){
+	"use strict";
+
+	var root = this,
+		Chart = root.Chart,
+		helpers = Chart.helpers;
+
+
+	var defaultConfig = {
+		//Boolean - Whether the scale should start at zero, or an order of magnitude down from the lowest value
+		scaleBeginAtZero : true,
+
+		//Boolean - Whether grid lines are shown across the chart
+		scaleShowGridLines : true,
+
+		//String - Colour of the grid lines
+		scaleGridLineColor : "rgba(0,0,0,.05)",
+
+		//Number - Width of the grid lines
+		scaleGridLineWidth : 1,
+
+		//Boolean - Whether to show horizontal lines (except X axis)
+		scaleShowHorizontalLines: true,
+
+		//Boolean - Whether to show vertical lines (except Y axis)
+		scaleShowVerticalLines: true,
+
+		//Boolean - If there is a stroke on each bar
+		barShowStroke : true,
+
+		//Number - Pixel width of the bar stroke
+		barStrokeWidth : 2,
+
+		//Number - Spacing between each of the X value sets
+		barValueSpacing : 5,
+
+		//Number - Spacing between data sets within X values
+		barDatasetSpacing : 1,
+
+		//String - A legend template
+		legendTemplate : "
    -legend\"><% for (var i=0; i
  • \"><%if(datasets[i].label){%><%=datasets[i].label%><%}%>
  • <%}%>
" + + }; + + + Chart.Type.extend({ + name: "Bar", + defaults : defaultConfig, + initialize: function(data){ + + //Expose options as a scope variable here so we can access it in the ScaleClass + var options = this.options; + + this.ScaleClass = Chart.Scale.extend({ + offsetGridLines : true, + calculateBarX : function(datasetCount, datasetIndex, barIndex){ + //Reusable method for calculating the xPosition of a given bar based on datasetIndex & width of the bar + var xWidth = this.calculateBaseWidth(), + xAbsolute = this.calculateX(barIndex) - (xWidth/2), + barWidth = this.calculateBarWidth(datasetCount); + + return xAbsolute + (barWidth * datasetIndex) + (datasetIndex * options.barDatasetSpacing) + barWidth/2; + }, + calculateBaseWidth : function(){ + return (this.calculateX(1) - this.calculateX(0)) - (2*options.barValueSpacing); + }, + calculateBarWidth : function(datasetCount){ + //The padding between datasets is to the right of each bar, providing that there are more than 1 dataset + var baseWidth = this.calculateBaseWidth() - ((datasetCount - 1) * options.barDatasetSpacing); + + return (baseWidth / datasetCount); + } + }); + + this.datasets = []; + + //Set up tooltip events on the chart + if (this.options.showTooltips){ + helpers.bindEvents(this, this.options.tooltipEvents, function(evt){ + var activeBars = (evt.type !== 'mouseout') ? this.getBarsAtEvent(evt) : []; + + this.eachBars(function(bar){ + bar.restore(['fillColor', 'strokeColor']); + }); + helpers.each(activeBars, function(activeBar){ + activeBar.fillColor = activeBar.highlightFill; + activeBar.strokeColor = activeBar.highlightStroke; + }); + this.showTooltip(activeBars); + }); + } + + //Declare the extension of the default point, to cater for the options passed in to the constructor + this.BarClass = Chart.Rectangle.extend({ + strokeWidth : this.options.barStrokeWidth, + showStroke : this.options.barShowStroke, + ctx : this.chart.ctx + }); + + //Iterate through each of the datasets, and build this into a property of the chart + helpers.each(data.datasets,function(dataset,datasetIndex){ + + var datasetObject = { + label : dataset.label || null, + fillColor : dataset.fillColor, + strokeColor : dataset.strokeColor, + bars : [] + }; + + this.datasets.push(datasetObject); + + helpers.each(dataset.data,function(dataPoint,index){ + //Add a new point for each piece of data, passing any required data to draw. + datasetObject.bars.push(new this.BarClass({ + value : dataPoint, + label : data.labels[index], + datasetLabel: dataset.label, + strokeColor : dataset.strokeColor, + fillColor : dataset.fillColor, + highlightFill : dataset.highlightFill || dataset.fillColor, + highlightStroke : dataset.highlightStroke || dataset.strokeColor + })); + },this); + + },this); + + this.buildScale(data.labels); + + this.BarClass.prototype.base = this.scale.endPoint; + + this.eachBars(function(bar, index, datasetIndex){ + helpers.extend(bar, { + width : this.scale.calculateBarWidth(this.datasets.length), + x: this.scale.calculateBarX(this.datasets.length, datasetIndex, index), + y: this.scale.endPoint + }); + bar.save(); + }, this); + + this.render(); + }, + update : function(){ + this.scale.update(); + // Reset any highlight colours before updating. + helpers.each(this.activeElements, function(activeElement){ + activeElement.restore(['fillColor', 'strokeColor']); + }); + + this.eachBars(function(bar){ + bar.save(); + }); + this.render(); + }, + eachBars : function(callback){ + helpers.each(this.datasets,function(dataset, datasetIndex){ + helpers.each(dataset.bars, callback, this, datasetIndex); + },this); + }, + getBarsAtEvent : function(e){ + var barsArray = [], + eventPosition = helpers.getRelativePosition(e), + datasetIterator = function(dataset){ + barsArray.push(dataset.bars[barIndex]); + }, + barIndex; + + for (var datasetIndex = 0; datasetIndex < this.datasets.length; datasetIndex++) { + for (barIndex = 0; barIndex < this.datasets[datasetIndex].bars.length; barIndex++) { + if (this.datasets[datasetIndex].bars[barIndex].inRange(eventPosition.x,eventPosition.y)){ + helpers.each(this.datasets, datasetIterator); + return barsArray; + } + } + } + + return barsArray; + }, + buildScale : function(labels){ + var self = this; + + var dataTotal = function(){ + var values = []; + self.eachBars(function(bar){ + values.push(bar.value); + }); + return values; + }; + + var scaleOptions = { + templateString : this.options.scaleLabel, + height : this.chart.height, + width : this.chart.width, + ctx : this.chart.ctx, + textColor : this.options.scaleFontColor, + fontSize : this.options.scaleFontSize, + fontStyle : this.options.scaleFontStyle, + fontFamily : this.options.scaleFontFamily, + valuesCount : labels.length, + beginAtZero : this.options.scaleBeginAtZero, + integersOnly : this.options.scaleIntegersOnly, + calculateYRange: function(currentHeight){ + var updatedRanges = helpers.calculateScaleRange( + dataTotal(), + currentHeight, + this.fontSize, + this.beginAtZero, + this.integersOnly + ); + helpers.extend(this, updatedRanges); + }, + xLabels : labels, + font : helpers.fontString(this.options.scaleFontSize, this.options.scaleFontStyle, this.options.scaleFontFamily), + lineWidth : this.options.scaleLineWidth, + lineColor : this.options.scaleLineColor, + showHorizontalLines : this.options.scaleShowHorizontalLines, + showVerticalLines : this.options.scaleShowVerticalLines, + gridLineWidth : (this.options.scaleShowGridLines) ? this.options.scaleGridLineWidth : 0, + gridLineColor : (this.options.scaleShowGridLines) ? this.options.scaleGridLineColor : "rgba(0,0,0,0)", + padding : (this.options.showScale) ? 0 : (this.options.barShowStroke) ? this.options.barStrokeWidth : 0, + showLabels : this.options.scaleShowLabels, + display : this.options.showScale + }; + + if (this.options.scaleOverride){ + helpers.extend(scaleOptions, { + calculateYRange: helpers.noop, + steps: this.options.scaleSteps, + stepValue: this.options.scaleStepWidth, + min: this.options.scaleStartValue, + max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth) + }); + } + + this.scale = new this.ScaleClass(scaleOptions); + }, + addData : function(valuesArray,label){ + //Map the values array for each of the datasets + helpers.each(valuesArray,function(value,datasetIndex){ + //Add a new point for each piece of data, passing any required data to draw. + this.datasets[datasetIndex].bars.push(new this.BarClass({ + value : value, + label : label, + x: this.scale.calculateBarX(this.datasets.length, datasetIndex, this.scale.valuesCount+1), + y: this.scale.endPoint, + width : this.scale.calculateBarWidth(this.datasets.length), + base : this.scale.endPoint, + strokeColor : this.datasets[datasetIndex].strokeColor, + fillColor : this.datasets[datasetIndex].fillColor + })); + },this); + + this.scale.addXLabel(label); + //Then re-render the chart. + this.update(); + }, + removeData : function(){ + this.scale.removeXLabel(); + //Then re-render the chart. + helpers.each(this.datasets,function(dataset){ + dataset.bars.shift(); + },this); + this.update(); + }, + reflow : function(){ + helpers.extend(this.BarClass.prototype,{ + y: this.scale.endPoint, + base : this.scale.endPoint + }); + var newScaleProps = helpers.extend({ + height : this.chart.height, + width : this.chart.width + }); + this.scale.update(newScaleProps); + }, + draw : function(ease){ + var easingDecimal = ease || 1; + this.clear(); + + var ctx = this.chart.ctx; + + this.scale.draw(easingDecimal); + + //Draw all the bars for each dataset + helpers.each(this.datasets,function(dataset,datasetIndex){ + helpers.each(dataset.bars,function(bar,index){ + if (bar.hasValue()){ + bar.base = this.scale.endPoint; + //Transition then draw + bar.transition({ + x : this.scale.calculateBarX(this.datasets.length, datasetIndex, index), + y : this.scale.calculateY(bar.value), + width : this.scale.calculateBarWidth(this.datasets.length) + }, easingDecimal).draw(); + } + },this); + + },this); + } + }); + + +}).call(window); + +(function(){ + "use strict"; + + var root = this, + Chart = root.Chart, + //Cache a local reference to Chart.helpers + helpers = Chart.helpers; + + var defaultConfig = { + //Boolean - Whether we should show a stroke on each segment + segmentShowStroke : true, + + //String - The colour of each segment stroke + segmentStrokeColor : "#fff", + + //Number - The width of each segment stroke + segmentStrokeWidth : 2, + + //The percentage of the chart that we cut out of the middle. + percentageInnerCutout : 50, + + //Number - Amount of animation steps + animationSteps : 100, + + //String - Animation easing effect + animationEasing : "easeOutBounce", + + //Boolean - Whether we animate the rotation of the Doughnut + animateRotate : true, + + //Boolean - Whether we animate scaling the Doughnut from the centre + animateScale : false, + + //String - A legend template + legendTemplate : "
    -legend\"><% for (var i=0; i
  • \"><%if(segments[i].label){%><%=segments[i].label%><%}%>
  • <%}%>
" + + }; + + + Chart.Type.extend({ + //Passing in a name registers this chart in the Chart namespace + name: "Doughnut", + //Providing a defaults will also register the deafults in the chart namespace + defaults : defaultConfig, + //Initialize is fired when the chart is initialized - Data is passed in as a parameter + //Config is automatically merged by the core of Chart.js, and is available at this.options + initialize: function(data){ + + //Declare segments as a static property to prevent inheriting across the Chart type prototype + this.segments = []; + this.outerRadius = (helpers.min([this.chart.width,this.chart.height]) - this.options.segmentStrokeWidth/2)/2; + + this.SegmentArc = Chart.Arc.extend({ + ctx : this.chart.ctx, + x : this.chart.width/2, + y : this.chart.height/2 + }); + + //Set up tooltip events on the chart + if (this.options.showTooltips){ + helpers.bindEvents(this, this.options.tooltipEvents, function(evt){ + var activeSegments = (evt.type !== 'mouseout') ? this.getSegmentsAtEvent(evt) : []; + + helpers.each(this.segments,function(segment){ + segment.restore(["fillColor"]); + }); + helpers.each(activeSegments,function(activeSegment){ + activeSegment.fillColor = activeSegment.highlightColor; + }); + this.showTooltip(activeSegments); + }); + } + this.calculateTotal(data); + + helpers.each(data,function(datapoint, index){ + this.addData(datapoint, index, true); + },this); + + this.render(); + }, + getSegmentsAtEvent : function(e){ + var segmentsArray = []; + + var location = helpers.getRelativePosition(e); + + helpers.each(this.segments,function(segment){ + if (segment.inRange(location.x,location.y)) segmentsArray.push(segment); + },this); + return segmentsArray; + }, + addData : function(segment, atIndex, silent){ + var index = atIndex || this.segments.length; + this.segments.splice(index, 0, new this.SegmentArc({ + value : segment.value, + outerRadius : (this.options.animateScale) ? 0 : this.outerRadius, + innerRadius : (this.options.animateScale) ? 0 : (this.outerRadius/100) * this.options.percentageInnerCutout, + fillColor : segment.color, + highlightColor : segment.highlight || segment.color, + showStroke : this.options.segmentShowStroke, + strokeWidth : this.options.segmentStrokeWidth, + strokeColor : this.options.segmentStrokeColor, + startAngle : Math.PI * 1.5, + circumference : (this.options.animateRotate) ? 0 : this.calculateCircumference(segment.value), + label : segment.label + })); + if (!silent){ + this.reflow(); + this.update(); + } + }, + calculateCircumference : function(value){ + return (Math.PI*2)*(Math.abs(value) / this.total); + }, + calculateTotal : function(data){ + this.total = 0; + helpers.each(data,function(segment){ + this.total += Math.abs(segment.value); + },this); + }, + update : function(){ + this.calculateTotal(this.segments); + + // Reset any highlight colours before updating. + helpers.each(this.activeElements, function(activeElement){ + activeElement.restore(['fillColor']); + }); + + helpers.each(this.segments,function(segment){ + segment.save(); + }); + this.render(); + }, + + removeData: function(atIndex){ + var indexToDelete = (helpers.isNumber(atIndex)) ? atIndex : this.segments.length-1; + this.segments.splice(indexToDelete, 1); + this.reflow(); + this.update(); + }, + + reflow : function(){ + helpers.extend(this.SegmentArc.prototype,{ + x : this.chart.width/2, + y : this.chart.height/2 + }); + this.outerRadius = (helpers.min([this.chart.width,this.chart.height]) - this.options.segmentStrokeWidth/2)/2; + helpers.each(this.segments, function(segment){ + segment.update({ + outerRadius : this.outerRadius, + innerRadius : (this.outerRadius/100) * this.options.percentageInnerCutout + }); + }, this); + }, + draw : function(easeDecimal){ + var animDecimal = (easeDecimal) ? easeDecimal : 1; + this.clear(); + helpers.each(this.segments,function(segment,index){ + segment.transition({ + circumference : this.calculateCircumference(segment.value), + outerRadius : this.outerRadius, + innerRadius : (this.outerRadius/100) * this.options.percentageInnerCutout + },animDecimal); + + segment.endAngle = segment.startAngle + segment.circumference; + + segment.draw(); + if (index === 0){ + segment.startAngle = Math.PI * 1.5; + } + //Check to see if it's the last segment, if not get the next and update the start angle + if (index < this.segments.length-1){ + this.segments[index+1].startAngle = segment.endAngle; + } + },this); + + } + }); + + Chart.types.Doughnut.extend({ + name : "Pie", + defaults : helpers.merge(defaultConfig,{percentageInnerCutout : 0}) + }); + +}).call(window); +(function(){ + "use strict"; + + var root = this, + Chart = root.Chart, + helpers = Chart.helpers; + + var defaultConfig = { + + ///Boolean - Whether grid lines are shown across the chart + scaleShowGridLines : true, + + //String - Colour of the grid lines + scaleGridLineColor : "rgba(0,0,0,.05)", + + //Number - Width of the grid lines + scaleGridLineWidth : 1, + + //Boolean - Whether to show horizontal lines (except X axis) + scaleShowHorizontalLines: true, + + //Boolean - Whether to show vertical lines (except Y axis) + scaleShowVerticalLines: true, + + //Boolean - Whether the line is curved between points + bezierCurve : true, + + //Number - Tension of the bezier curve between points + bezierCurveTension : 0.4, + + //Boolean - Whether to show a dot for each point + pointDot : true, + + //Number - Radius of each point dot in pixels + pointDotRadius : 4, + + //Number - Pixel width of point dot stroke + pointDotStrokeWidth : 1, + + //Number - amount extra to add to the radius to cater for hit detection outside the drawn point + pointHitDetectionRadius : 20, + + //Boolean - Whether to show a stroke for datasets + datasetStroke : true, + + //Number - Pixel width of dataset stroke + datasetStrokeWidth : 2, + + //Boolean - Whether to fill the dataset with a colour + datasetFill : true, + + //String - A legend template + legendTemplate : "
    -legend\"><% for (var i=0; i
  • \"><%if(datasets[i].label){%><%=datasets[i].label%><%}%>
  • <%}%>
" + + }; + + + Chart.Type.extend({ + name: "Line", + defaults : defaultConfig, + initialize: function(data){ + //Declare the extension of the default point, to cater for the options passed in to the constructor + this.PointClass = Chart.Point.extend({ + strokeWidth : this.options.pointDotStrokeWidth, + radius : this.options.pointDotRadius, + display: this.options.pointDot, + hitDetectionRadius : this.options.pointHitDetectionRadius, + ctx : this.chart.ctx, + inRange : function(mouseX){ + return (Math.pow(mouseX-this.x, 2) < Math.pow(this.radius + this.hitDetectionRadius,2)); + } + }); + + this.datasets = []; + + //Set up tooltip events on the chart + if (this.options.showTooltips){ + helpers.bindEvents(this, this.options.tooltipEvents, function(evt){ + var activePoints = (evt.type !== 'mouseout') ? this.getPointsAtEvent(evt) : []; + this.eachPoints(function(point){ + point.restore(['fillColor', 'strokeColor']); + }); + helpers.each(activePoints, function(activePoint){ + activePoint.fillColor = activePoint.highlightFill; + activePoint.strokeColor = activePoint.highlightStroke; + }); + this.showTooltip(activePoints); + }); + } + + //Iterate through each of the datasets, and build this into a property of the chart + helpers.each(data.datasets,function(dataset){ + + var datasetObject = { + label : dataset.label || null, + fillColor : dataset.fillColor, + strokeColor : dataset.strokeColor, + pointColor : dataset.pointColor, + pointStrokeColor : dataset.pointStrokeColor, + points : [] + }; + + this.datasets.push(datasetObject); + + + helpers.each(dataset.data,function(dataPoint,index){ + //Add a new point for each piece of data, passing any required data to draw. + datasetObject.points.push(new this.PointClass({ + value : dataPoint, + label : data.labels[index], + datasetLabel: dataset.label, + strokeColor : dataset.pointStrokeColor, + fillColor : dataset.pointColor, + highlightFill : dataset.pointHighlightFill || dataset.pointColor, + highlightStroke : dataset.pointHighlightStroke || dataset.pointStrokeColor + })); + },this); + + this.buildScale(data.labels); + + + this.eachPoints(function(point, index){ + helpers.extend(point, { + x: this.scale.calculateX(index), + y: this.scale.endPoint + }); + point.save(); + }, this); + + },this); + + + this.render(); + }, + update : function(){ + this.scale.update(); + // Reset any highlight colours before updating. + helpers.each(this.activeElements, function(activeElement){ + activeElement.restore(['fillColor', 'strokeColor']); + }); + this.eachPoints(function(point){ + point.save(); + }); + this.render(); + }, + eachPoints : function(callback){ + helpers.each(this.datasets,function(dataset){ + helpers.each(dataset.points,callback,this); + },this); + }, + getPointsAtEvent : function(e){ + var pointsArray = [], + eventPosition = helpers.getRelativePosition(e); + helpers.each(this.datasets,function(dataset){ + helpers.each(dataset.points,function(point){ + if (point.inRange(eventPosition.x,eventPosition.y)) pointsArray.push(point); + }); + },this); + return pointsArray; + }, + buildScale : function(labels){ + var self = this; + + var dataTotal = function(){ + var values = []; + self.eachPoints(function(point){ + values.push(point.value); + }); + + return values; + }; + + var scaleOptions = { + templateString : this.options.scaleLabel, + height : this.chart.height, + width : this.chart.width, + ctx : this.chart.ctx, + textColor : this.options.scaleFontColor, + fontSize : this.options.scaleFontSize, + fontStyle : this.options.scaleFontStyle, + fontFamily : this.options.scaleFontFamily, + valuesCount : labels.length, + beginAtZero : this.options.scaleBeginAtZero, + integersOnly : this.options.scaleIntegersOnly, + calculateYRange : function(currentHeight){ + var updatedRanges = helpers.calculateScaleRange( + dataTotal(), + currentHeight, + this.fontSize, + this.beginAtZero, + this.integersOnly + ); + helpers.extend(this, updatedRanges); + }, + xLabels : labels, + font : helpers.fontString(this.options.scaleFontSize, this.options.scaleFontStyle, this.options.scaleFontFamily), + lineWidth : this.options.scaleLineWidth, + lineColor : this.options.scaleLineColor, + showHorizontalLines : this.options.scaleShowHorizontalLines, + showVerticalLines : this.options.scaleShowVerticalLines, + gridLineWidth : (this.options.scaleShowGridLines) ? this.options.scaleGridLineWidth : 0, + gridLineColor : (this.options.scaleShowGridLines) ? this.options.scaleGridLineColor : "rgba(0,0,0,0)", + padding: (this.options.showScale) ? 0 : this.options.pointDotRadius + this.options.pointDotStrokeWidth, + showLabels : this.options.scaleShowLabels, + display : this.options.showScale + }; + + if (this.options.scaleOverride){ + helpers.extend(scaleOptions, { + calculateYRange: helpers.noop, + steps: this.options.scaleSteps, + stepValue: this.options.scaleStepWidth, + min: this.options.scaleStartValue, + max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth) + }); + } + + + this.scale = new Chart.Scale(scaleOptions); + }, + addData : function(valuesArray,label){ + //Map the values array for each of the datasets + + helpers.each(valuesArray,function(value,datasetIndex){ + //Add a new point for each piece of data, passing any required data to draw. + this.datasets[datasetIndex].points.push(new this.PointClass({ + value : value, + label : label, + x: this.scale.calculateX(this.scale.valuesCount+1), + y: this.scale.endPoint, + strokeColor : this.datasets[datasetIndex].pointStrokeColor, + fillColor : this.datasets[datasetIndex].pointColor + })); + },this); + + this.scale.addXLabel(label); + //Then re-render the chart. + this.update(); + }, + removeData : function(){ + this.scale.removeXLabel(); + //Then re-render the chart. + helpers.each(this.datasets,function(dataset){ + dataset.points.shift(); + },this); + this.update(); + }, + reflow : function(){ + var newScaleProps = helpers.extend({ + height : this.chart.height, + width : this.chart.width + }); + this.scale.update(newScaleProps); + }, + draw : function(ease){ + var easingDecimal = ease || 1; + this.clear(); + + var ctx = this.chart.ctx; + + // Some helper methods for getting the next/prev points + var hasValue = function(item){ + return item.value !== null; + }, + nextPoint = function(point, collection, index){ + return helpers.findNextWhere(collection, hasValue, index) || point; + }, + previousPoint = function(point, collection, index){ + return helpers.findPreviousWhere(collection, hasValue, index) || point; + }; + + this.scale.draw(easingDecimal); + + + helpers.each(this.datasets,function(dataset){ + var pointsWithValues = helpers.where(dataset.points, hasValue); + + //Transition each point first so that the line and point drawing isn't out of sync + //We can use this extra loop to calculate the control points of this dataset also in this loop + + helpers.each(dataset.points, function(point, index){ + if (point.hasValue()){ + point.transition({ + y : this.scale.calculateY(point.value), + x : this.scale.calculateX(index) + }, easingDecimal); + } + },this); + + + // Control points need to be calculated in a seperate loop, because we need to know the current x/y of the point + // This would cause issues when there is no animation, because the y of the next point would be 0, so beziers would be skewed + if (this.options.bezierCurve){ + helpers.each(pointsWithValues, function(point, index){ + var tension = (index > 0 && index < pointsWithValues.length - 1) ? this.options.bezierCurveTension : 0; + point.controlPoints = helpers.splineCurve( + previousPoint(point, pointsWithValues, index), + point, + nextPoint(point, pointsWithValues, index), + tension + ); + + // Prevent the bezier going outside of the bounds of the graph + + // Cap puter bezier handles to the upper/lower scale bounds + if (point.controlPoints.outer.y > this.scale.endPoint){ + point.controlPoints.outer.y = this.scale.endPoint; + } + else if (point.controlPoints.outer.y < this.scale.startPoint){ + point.controlPoints.outer.y = this.scale.startPoint; + } + + // Cap inner bezier handles to the upper/lower scale bounds + if (point.controlPoints.inner.y > this.scale.endPoint){ + point.controlPoints.inner.y = this.scale.endPoint; + } + else if (point.controlPoints.inner.y < this.scale.startPoint){ + point.controlPoints.inner.y = this.scale.startPoint; + } + },this); + } + + + //Draw the line between all the points + ctx.lineWidth = this.options.datasetStrokeWidth; + ctx.strokeStyle = dataset.strokeColor; + ctx.beginPath(); + + helpers.each(pointsWithValues, function(point, index){ + if (index === 0){ + ctx.moveTo(point.x, point.y); + } + else{ + if(this.options.bezierCurve){ + var previous = previousPoint(point, pointsWithValues, index); + + ctx.bezierCurveTo( + previous.controlPoints.outer.x, + previous.controlPoints.outer.y, + point.controlPoints.inner.x, + point.controlPoints.inner.y, + point.x, + point.y + ); + } + else{ + ctx.lineTo(point.x,point.y); + } + } + }, this); + + ctx.stroke(); + + if (this.options.datasetFill && pointsWithValues.length > 0){ + //Round off the line by going to the base of the chart, back to the start, then fill. + ctx.lineTo(pointsWithValues[pointsWithValues.length - 1].x, this.scale.endPoint); + ctx.lineTo(pointsWithValues[0].x, this.scale.endPoint); + ctx.fillStyle = dataset.fillColor; + ctx.closePath(); + ctx.fill(); + } + + //Now draw the points over the line + //A little inefficient double looping, but better than the line + //lagging behind the point positions + helpers.each(pointsWithValues,function(point){ + point.draw(); + }); + },this); + } + }); + + +}).call(window); + +(function(){ + "use strict"; + + var root = this, + Chart = root.Chart, + //Cache a local reference to Chart.helpers + helpers = Chart.helpers; + + var defaultConfig = { + //Boolean - Show a backdrop to the scale label + scaleShowLabelBackdrop : true, + + //String - The colour of the label backdrop + scaleBackdropColor : "rgba(255,255,255,0.75)", + + // Boolean - Whether the scale should begin at zero + scaleBeginAtZero : true, + + //Number - The backdrop padding above & below the label in pixels + scaleBackdropPaddingY : 2, + + //Number - The backdrop padding to the side of the label in pixels + scaleBackdropPaddingX : 2, + + //Boolean - Show line for each value in the scale + scaleShowLine : true, + + //Boolean - Stroke a line around each segment in the chart + segmentShowStroke : true, + + //String - The colour of the stroke on each segement. + segmentStrokeColor : "#fff", + + //Number - The width of the stroke value in pixels + segmentStrokeWidth : 2, + + //Number - Amount of animation steps + animationSteps : 100, + + //String - Animation easing effect. + animationEasing : "easeOutBounce", + + //Boolean - Whether to animate the rotation of the chart + animateRotate : true, + + //Boolean - Whether to animate scaling the chart from the centre + animateScale : false, + + //String - A legend template + legendTemplate : "
    -legend\"><% for (var i=0; i
  • \"><%if(segments[i].label){%><%=segments[i].label%><%}%>
  • <%}%>
" + }; + + + Chart.Type.extend({ + //Passing in a name registers this chart in the Chart namespace + name: "PolarArea", + //Providing a defaults will also register the deafults in the chart namespace + defaults : defaultConfig, + //Initialize is fired when the chart is initialized - Data is passed in as a parameter + //Config is automatically merged by the core of Chart.js, and is available at this.options + initialize: function(data){ + this.segments = []; + //Declare segment class as a chart instance specific class, so it can share props for this instance + this.SegmentArc = Chart.Arc.extend({ + showStroke : this.options.segmentShowStroke, + strokeWidth : this.options.segmentStrokeWidth, + strokeColor : this.options.segmentStrokeColor, + ctx : this.chart.ctx, + innerRadius : 0, + x : this.chart.width/2, + y : this.chart.height/2 + }); + this.scale = new Chart.RadialScale({ + display: this.options.showScale, + fontStyle: this.options.scaleFontStyle, + fontSize: this.options.scaleFontSize, + fontFamily: this.options.scaleFontFamily, + fontColor: this.options.scaleFontColor, + showLabels: this.options.scaleShowLabels, + showLabelBackdrop: this.options.scaleShowLabelBackdrop, + backdropColor: this.options.scaleBackdropColor, + backdropPaddingY : this.options.scaleBackdropPaddingY, + backdropPaddingX: this.options.scaleBackdropPaddingX, + lineWidth: (this.options.scaleShowLine) ? this.options.scaleLineWidth : 0, + lineColor: this.options.scaleLineColor, + lineArc: true, + width: this.chart.width, + height: this.chart.height, + xCenter: this.chart.width/2, + yCenter: this.chart.height/2, + ctx : this.chart.ctx, + templateString: this.options.scaleLabel, + valuesCount: data.length + }); + + this.updateScaleRange(data); + + this.scale.update(); + + helpers.each(data,function(segment,index){ + this.addData(segment,index,true); + },this); + + //Set up tooltip events on the chart + if (this.options.showTooltips){ + helpers.bindEvents(this, this.options.tooltipEvents, function(evt){ + var activeSegments = (evt.type !== 'mouseout') ? this.getSegmentsAtEvent(evt) : []; + helpers.each(this.segments,function(segment){ + segment.restore(["fillColor"]); + }); + helpers.each(activeSegments,function(activeSegment){ + activeSegment.fillColor = activeSegment.highlightColor; + }); + this.showTooltip(activeSegments); + }); + } + + this.render(); + }, + getSegmentsAtEvent : function(e){ + var segmentsArray = []; + + var location = helpers.getRelativePosition(e); + + helpers.each(this.segments,function(segment){ + if (segment.inRange(location.x,location.y)) segmentsArray.push(segment); + },this); + return segmentsArray; + }, + addData : function(segment, atIndex, silent){ + var index = atIndex || this.segments.length; + + this.segments.splice(index, 0, new this.SegmentArc({ + fillColor: segment.color, + highlightColor: segment.highlight || segment.color, + label: segment.label, + value: segment.value, + outerRadius: (this.options.animateScale) ? 0 : this.scale.calculateCenterOffset(segment.value), + circumference: (this.options.animateRotate) ? 0 : this.scale.getCircumference(), + startAngle: Math.PI * 1.5 + })); + if (!silent){ + this.reflow(); + this.update(); + } + }, + removeData: function(atIndex){ + var indexToDelete = (helpers.isNumber(atIndex)) ? atIndex : this.segments.length-1; + this.segments.splice(indexToDelete, 1); + this.reflow(); + this.update(); + }, + calculateTotal: function(data){ + this.total = 0; + helpers.each(data,function(segment){ + this.total += segment.value; + },this); + this.scale.valuesCount = this.segments.length; + }, + updateScaleRange: function(datapoints){ + var valuesArray = []; + helpers.each(datapoints,function(segment){ + valuesArray.push(segment.value); + }); + + var scaleSizes = (this.options.scaleOverride) ? + { + steps: this.options.scaleSteps, + stepValue: this.options.scaleStepWidth, + min: this.options.scaleStartValue, + max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth) + } : + helpers.calculateScaleRange( + valuesArray, + helpers.min([this.chart.width, this.chart.height])/2, + this.options.scaleFontSize, + this.options.scaleBeginAtZero, + this.options.scaleIntegersOnly + ); + + helpers.extend( + this.scale, + scaleSizes, + { + size: helpers.min([this.chart.width, this.chart.height]), + xCenter: this.chart.width/2, + yCenter: this.chart.height/2 + } + ); + + }, + update : function(){ + this.calculateTotal(this.segments); + + helpers.each(this.segments,function(segment){ + segment.save(); + }); + + this.reflow(); + this.render(); + }, + reflow : function(){ + helpers.extend(this.SegmentArc.prototype,{ + x : this.chart.width/2, + y : this.chart.height/2 + }); + this.updateScaleRange(this.segments); + this.scale.update(); + + helpers.extend(this.scale,{ + xCenter: this.chart.width/2, + yCenter: this.chart.height/2 + }); + + helpers.each(this.segments, function(segment){ + segment.update({ + outerRadius : this.scale.calculateCenterOffset(segment.value) + }); + }, this); + + }, + draw : function(ease){ + var easingDecimal = ease || 1; + //Clear & draw the canvas + this.clear(); + helpers.each(this.segments,function(segment, index){ + segment.transition({ + circumference : this.scale.getCircumference(), + outerRadius : this.scale.calculateCenterOffset(segment.value) + },easingDecimal); + + segment.endAngle = segment.startAngle + segment.circumference; + + // If we've removed the first segment we need to set the first one to + // start at the top. + if (index === 0){ + segment.startAngle = Math.PI * 1.5; + } + + //Check to see if it's the last segment, if not get the next and update the start angle + if (index < this.segments.length - 1){ + this.segments[index+1].startAngle = segment.endAngle; + } + segment.draw(); + }, this); + this.scale.draw(); + } + }); + +}).call(window); +(function(){ + "use strict"; + + var root = this, + Chart = root.Chart, + helpers = Chart.helpers; + + + + Chart.Type.extend({ + name: "Radar", + defaults:{ + //Boolean - Whether to show lines for each scale point + scaleShowLine : true, + + //Boolean - Whether we show the angle lines out of the radar + angleShowLineOut : true, + + //Boolean - Whether to show labels on the scale + scaleShowLabels : false, + + // Boolean - Whether the scale should begin at zero + scaleBeginAtZero : true, + + //String - Colour of the angle line + angleLineColor : "rgba(0,0,0,.1)", + + //Number - Pixel width of the angle line + angleLineWidth : 1, + + //String - Point label font declaration + pointLabelFontFamily : "'Arial'", + + //String - Point label font weight + pointLabelFontStyle : "normal", + + //Number - Point label font size in pixels + pointLabelFontSize : 10, + + //String - Point label font colour + pointLabelFontColor : "#666", + + //Boolean - Whether to show a dot for each point + pointDot : true, + + //Number - Radius of each point dot in pixels + pointDotRadius : 3, + + //Number - Pixel width of point dot stroke + pointDotStrokeWidth : 1, + + //Number - amount extra to add to the radius to cater for hit detection outside the drawn point + pointHitDetectionRadius : 20, + + //Boolean - Whether to show a stroke for datasets + datasetStroke : true, + + //Number - Pixel width of dataset stroke + datasetStrokeWidth : 2, + + //Boolean - Whether to fill the dataset with a colour + datasetFill : true, + + //String - A legend template + legendTemplate : "
    -legend\"><% for (var i=0; i
  • \"><%if(datasets[i].label){%><%=datasets[i].label%><%}%>
  • <%}%>
" + + }, + + initialize: function(data){ + this.PointClass = Chart.Point.extend({ + strokeWidth : this.options.pointDotStrokeWidth, + radius : this.options.pointDotRadius, + display: this.options.pointDot, + hitDetectionRadius : this.options.pointHitDetectionRadius, + ctx : this.chart.ctx + }); + + this.datasets = []; + + this.buildScale(data); + + //Set up tooltip events on the chart + if (this.options.showTooltips){ + helpers.bindEvents(this, this.options.tooltipEvents, function(evt){ + var activePointsCollection = (evt.type !== 'mouseout') ? this.getPointsAtEvent(evt) : []; + + this.eachPoints(function(point){ + point.restore(['fillColor', 'strokeColor']); + }); + helpers.each(activePointsCollection, function(activePoint){ + activePoint.fillColor = activePoint.highlightFill; + activePoint.strokeColor = activePoint.highlightStroke; + }); + + this.showTooltip(activePointsCollection); + }); + } + + //Iterate through each of the datasets, and build this into a property of the chart + helpers.each(data.datasets,function(dataset){ + + var datasetObject = { + label: dataset.label || null, + fillColor : dataset.fillColor, + strokeColor : dataset.strokeColor, + pointColor : dataset.pointColor, + pointStrokeColor : dataset.pointStrokeColor, + points : [] + }; + + this.datasets.push(datasetObject); + + helpers.each(dataset.data,function(dataPoint,index){ + //Add a new point for each piece of data, passing any required data to draw. + var pointPosition; + if (!this.scale.animation){ + pointPosition = this.scale.getPointPosition(index, this.scale.calculateCenterOffset(dataPoint)); + } + datasetObject.points.push(new this.PointClass({ + value : dataPoint, + label : data.labels[index], + datasetLabel: dataset.label, + x: (this.options.animation) ? this.scale.xCenter : pointPosition.x, + y: (this.options.animation) ? this.scale.yCenter : pointPosition.y, + strokeColor : dataset.pointStrokeColor, + fillColor : dataset.pointColor, + highlightFill : dataset.pointHighlightFill || dataset.pointColor, + highlightStroke : dataset.pointHighlightStroke || dataset.pointStrokeColor + })); + },this); + + },this); + + this.render(); + }, + eachPoints : function(callback){ + helpers.each(this.datasets,function(dataset){ + helpers.each(dataset.points,callback,this); + },this); + }, + + getPointsAtEvent : function(evt){ + var mousePosition = helpers.getRelativePosition(evt), + fromCenter = helpers.getAngleFromPoint({ + x: this.scale.xCenter, + y: this.scale.yCenter + }, mousePosition); + + var anglePerIndex = (Math.PI * 2) /this.scale.valuesCount, + pointIndex = Math.round((fromCenter.angle - Math.PI * 1.5) / anglePerIndex), + activePointsCollection = []; + + // If we're at the top, make the pointIndex 0 to get the first of the array. + if (pointIndex >= this.scale.valuesCount || pointIndex < 0){ + pointIndex = 0; + } + + if (fromCenter.distance <= this.scale.drawingArea){ + helpers.each(this.datasets, function(dataset){ + activePointsCollection.push(dataset.points[pointIndex]); + }); + } + + return activePointsCollection; + }, + + buildScale : function(data){ + this.scale = new Chart.RadialScale({ + display: this.options.showScale, + fontStyle: this.options.scaleFontStyle, + fontSize: this.options.scaleFontSize, + fontFamily: this.options.scaleFontFamily, + fontColor: this.options.scaleFontColor, + showLabels: this.options.scaleShowLabels, + showLabelBackdrop: this.options.scaleShowLabelBackdrop, + backdropColor: this.options.scaleBackdropColor, + backdropPaddingY : this.options.scaleBackdropPaddingY, + backdropPaddingX: this.options.scaleBackdropPaddingX, + lineWidth: (this.options.scaleShowLine) ? this.options.scaleLineWidth : 0, + lineColor: this.options.scaleLineColor, + angleLineColor : this.options.angleLineColor, + angleLineWidth : (this.options.angleShowLineOut) ? this.options.angleLineWidth : 0, + // Point labels at the edge of each line + pointLabelFontColor : this.options.pointLabelFontColor, + pointLabelFontSize : this.options.pointLabelFontSize, + pointLabelFontFamily : this.options.pointLabelFontFamily, + pointLabelFontStyle : this.options.pointLabelFontStyle, + height : this.chart.height, + width: this.chart.width, + xCenter: this.chart.width/2, + yCenter: this.chart.height/2, + ctx : this.chart.ctx, + templateString: this.options.scaleLabel, + labels: data.labels, + valuesCount: data.datasets[0].data.length + }); + + this.scale.setScaleSize(); + this.updateScaleRange(data.datasets); + this.scale.buildYLabels(); + }, + updateScaleRange: function(datasets){ + var valuesArray = (function(){ + var totalDataArray = []; + helpers.each(datasets,function(dataset){ + if (dataset.data){ + totalDataArray = totalDataArray.concat(dataset.data); + } + else { + helpers.each(dataset.points, function(point){ + totalDataArray.push(point.value); + }); + } + }); + return totalDataArray; + })(); + + + var scaleSizes = (this.options.scaleOverride) ? + { + steps: this.options.scaleSteps, + stepValue: this.options.scaleStepWidth, + min: this.options.scaleStartValue, + max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth) + } : + helpers.calculateScaleRange( + valuesArray, + helpers.min([this.chart.width, this.chart.height])/2, + this.options.scaleFontSize, + this.options.scaleBeginAtZero, + this.options.scaleIntegersOnly + ); + + helpers.extend( + this.scale, + scaleSizes + ); + + }, + addData : function(valuesArray,label){ + //Map the values array for each of the datasets + this.scale.valuesCount++; + helpers.each(valuesArray,function(value,datasetIndex){ + var pointPosition = this.scale.getPointPosition(this.scale.valuesCount, this.scale.calculateCenterOffset(value)); + this.datasets[datasetIndex].points.push(new this.PointClass({ + value : value, + label : label, + x: pointPosition.x, + y: pointPosition.y, + strokeColor : this.datasets[datasetIndex].pointStrokeColor, + fillColor : this.datasets[datasetIndex].pointColor + })); + },this); + + this.scale.labels.push(label); + + this.reflow(); + + this.update(); + }, + removeData : function(){ + this.scale.valuesCount--; + this.scale.labels.shift(); + helpers.each(this.datasets,function(dataset){ + dataset.points.shift(); + },this); + this.reflow(); + this.update(); + }, + update : function(){ + this.eachPoints(function(point){ + point.save(); + }); + this.reflow(); + this.render(); + }, + reflow: function(){ + helpers.extend(this.scale, { + width : this.chart.width, + height: this.chart.height, + size : helpers.min([this.chart.width, this.chart.height]), + xCenter: this.chart.width/2, + yCenter: this.chart.height/2 + }); + this.updateScaleRange(this.datasets); + this.scale.setScaleSize(); + this.scale.buildYLabels(); + }, + draw : function(ease){ + var easeDecimal = ease || 1, + ctx = this.chart.ctx; + this.clear(); + this.scale.draw(); + + helpers.each(this.datasets,function(dataset){ + + //Transition each point first so that the line and point drawing isn't out of sync + helpers.each(dataset.points,function(point,index){ + if (point.hasValue()){ + point.transition(this.scale.getPointPosition(index, this.scale.calculateCenterOffset(point.value)), easeDecimal); + } + },this); + + + + //Draw the line between all the points + ctx.lineWidth = this.options.datasetStrokeWidth; + ctx.strokeStyle = dataset.strokeColor; + ctx.beginPath(); + helpers.each(dataset.points,function(point,index){ + if (index === 0){ + ctx.moveTo(point.x,point.y); + } + else{ + ctx.lineTo(point.x,point.y); + } + },this); + ctx.closePath(); + ctx.stroke(); + + ctx.fillStyle = dataset.fillColor; + ctx.fill(); + + //Now draw the points over the line + //A little inefficient double looping, but better than the line + //lagging behind the point positions + helpers.each(dataset.points,function(point){ + if (point.hasValue()){ + point.draw(); + } + }); + + },this); + + } + + }); + + +}).call(window); + diff --git a/app/javascript/legacy/common/vendor/bootstrap-tour-standalone.js b/app/javascript/legacy/common/vendor/bootstrap-tour-standalone.js new file mode 100644 index 00000000..9155204b --- /dev/null +++ b/app/javascript/legacy/common/vendor/bootstrap-tour-standalone.js @@ -0,0 +1,1288 @@ +// License: LGPL-3.0-or-later +/* =========================================================== +# bootstrap-tour - v0.9.3 +# http://bootstraptour.com +# ============================================================== +# Copyright 2012-2013 Ulrich Sossou +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +*/ +/* ======================================================================== + * Bootstrap: transition.js v3.1.1 + * http://getbootstrap.com/javascript/#transitions + * ======================================================================== + * Copyright 2011-2014 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // CSS TRANSITION SUPPORT (Shoutout: http://www.modernizr.com/) + // ============================================================ + + function transitionEnd() { + var el = document.createElement('bootstrap') + + var transEndEventNames = { + 'WebkitTransition' : 'webkitTransitionEnd', + 'MozTransition' : 'transitionend', + 'OTransition' : 'oTransitionEnd otransitionend', + 'transition' : 'transitionend' + } + + for (var name in transEndEventNames) { + if (el.style[name] !== undefined) { + return { end: transEndEventNames[name] } + } + } + + return false // explicit for ie8 ( ._.) + } + + // http://blog.alexmaccaw.com/css-transitions + $.fn.emulateTransitionEnd = function (duration) { + var called = false, $el = this + $(this).one($.support.transition.end, function () { called = true }) + var callback = function () { if (!called) $($el).trigger($.support.transition.end) } + setTimeout(callback, duration) + return this + } + + $(function () { + $.support.transition = transitionEnd() + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: tooltip.js v3.1.1 + * http://getbootstrap.com/javascript/#tooltip + * Inspired by the original jQuery.tipsy by Jason Frame + * ======================================================================== + * Copyright 2011-2014 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // TOOLTIP PUBLIC CLASS DEFINITION + // =============================== + + var Tooltip = function (element, options) { + this.type = + this.options = + this.enabled = + this.timeout = + this.hoverState = + this.$element = null + + this.init('tooltip', element, options) + } + + Tooltip.DEFAULTS = { + animation: true, + placement: 'top', + selector: false, + template: '
', + trigger: 'hover focus', + title: '', + delay: 0, + html: false, + container: false + } + + Tooltip.prototype.init = function (type, element, options) { + this.enabled = true + this.type = type + this.$element = $(element) + this.options = this.getOptions(options) + + var triggers = this.options.trigger.split(' ') + + for (var i = triggers.length; i--;) { + var trigger = triggers[i] + + if (trigger == 'click') { + this.$element.on('click.' + this.type, this.options.selector, $.proxy(this.toggle, this)) + } else if (trigger != 'manual') { + var eventIn = trigger == 'hover' ? 'mouseenter' : 'focusin' + var eventOut = trigger == 'hover' ? 'mouseleave' : 'focusout' + + this.$element.on(eventIn + '.' + this.type, this.options.selector, $.proxy(this.enter, this)) + this.$element.on(eventOut + '.' + this.type, this.options.selector, $.proxy(this.leave, this)) + } + } + + this.options.selector ? + (this._options = $.extend({}, this.options, { trigger: 'manual', selector: '' })) : + this.fixTitle() + } + + Tooltip.prototype.getDefaults = function () { + return Tooltip.DEFAULTS + } + + Tooltip.prototype.getOptions = function (options) { + options = $.extend({}, this.getDefaults(), this.$element.data(), options) + + if (options.delay && typeof options.delay == 'number') { + options.delay = { + show: options.delay, + hide: options.delay + } + } + + return options + } + + Tooltip.prototype.getDelegateOptions = function () { + var options = {} + var defaults = this.getDefaults() + + this._options && $.each(this._options, function (key, value) { + if (defaults[key] != value) options[key] = value + }) + + return options + } + + Tooltip.prototype.enter = function (obj) { + var self = obj instanceof this.constructor ? + obj : $(obj.currentTarget)[this.type](this.getDelegateOptions()).data('bs.' + this.type) + + clearTimeout(self.timeout) + + self.hoverState = 'in' + + if (!self.options.delay || !self.options.delay.show) return self.show() + + self.timeout = setTimeout(function () { + if (self.hoverState == 'in') self.show() + }, self.options.delay.show) + } + + Tooltip.prototype.leave = function (obj) { + var self = obj instanceof this.constructor ? + obj : $(obj.currentTarget)[this.type](this.getDelegateOptions()).data('bs.' + this.type) + + clearTimeout(self.timeout) + + self.hoverState = 'out' + + if (!self.options.delay || !self.options.delay.hide) return self.hide() + + self.timeout = setTimeout(function () { + if (self.hoverState == 'out') self.hide() + }, self.options.delay.hide) + } + + Tooltip.prototype.show = function () { + var e = $.Event('show.bs.' + this.type) + + if (this.hasContent() && this.enabled) { + this.$element.trigger(e) + + if (e.isDefaultPrevented()) return + var that = this; + + var $tip = this.tip() + + this.setContent() + + if (this.options.animation) $tip.addClass('fade') + + var placement = typeof this.options.placement == 'function' ? + this.options.placement.call(this, $tip[0], this.$element[0]) : + this.options.placement + + var autoToken = /\s?auto?\s?/i + var autoPlace = autoToken.test(placement) + if (autoPlace) placement = placement.replace(autoToken, '') || 'top' + + $tip + .detach() + .css({ top: 0, left: 0, display: 'block' }) + .addClass(placement) + + this.options.container ? $tip.appendTo(this.options.container) : $tip.insertAfter(this.$element) + + var pos = this.getPosition() + var actualWidth = $tip[0].offsetWidth + var actualHeight = $tip[0].offsetHeight + + if (autoPlace) { + var $parent = this.$element.parent() + + var orgPlacement = placement + var docScroll = document.documentElement.scrollTop || document.body.scrollTop + var parentWidth = this.options.container == 'body' ? window.innerWidth : $parent.outerWidth() + var parentHeight = this.options.container == 'body' ? window.innerHeight : $parent.outerHeight() + var parentLeft = this.options.container == 'body' ? 0 : $parent.offset().left + + placement = placement == 'bottom' && pos.top + pos.height + actualHeight - docScroll > parentHeight ? 'top' : + placement == 'top' && pos.top - docScroll - actualHeight < 0 ? 'bottom' : + placement == 'right' && pos.right + actualWidth > parentWidth ? 'left' : + placement == 'left' && pos.left - actualWidth < parentLeft ? 'right' : + placement + + $tip + .removeClass(orgPlacement) + .addClass(placement) + } + + var calculatedOffset = this.getCalculatedOffset(placement, pos, actualWidth, actualHeight) + + this.applyPlacement(calculatedOffset, placement) + this.hoverState = null + + var complete = function() { + that.$element.trigger('shown.bs.' + that.type) + } + + $.support.transition && this.$tip.hasClass('fade') ? + $tip + .one($.support.transition.end, complete) + .emulateTransitionEnd(150) : + complete() + } + } + + Tooltip.prototype.applyPlacement = function (offset, placement) { + var replace + var $tip = this.tip() + var width = $tip[0].offsetWidth + var height = $tip[0].offsetHeight + + // manually read margins because getBoundingClientRect includes difference + var marginTop = parseInt($tip.css('margin-top'), 10) + var marginLeft = parseInt($tip.css('margin-left'), 10) + + // we must check for NaN for ie 8/9 + if (isNaN(marginTop)) marginTop = 0 + if (isNaN(marginLeft)) marginLeft = 0 + + offset.top = offset.top + marginTop + offset.left = offset.left + marginLeft + + // $.fn.offset doesn't round pixel values + // so we use setOffset directly with our own function B-0 + $.offset.setOffset($tip[0], $.extend({ + using: function (props) { + $tip.css({ + top: Math.round(props.top), + left: Math.round(props.left) + }) + } + }, offset), 0) + + $tip.addClass('in') + + // check to see if placing tip in new offset caused the tip to resize itself + var actualWidth = $tip[0].offsetWidth + var actualHeight = $tip[0].offsetHeight + + if (placement == 'top' && actualHeight != height) { + replace = true + offset.top = offset.top + height - actualHeight + } + + if (/bottom|top/.test(placement)) { + var delta = 0 + + if (offset.left < 0) { + delta = offset.left * -2 + offset.left = 0 + + $tip.offset(offset) + + actualWidth = $tip[0].offsetWidth + actualHeight = $tip[0].offsetHeight + } + + this.replaceArrow(delta - width + actualWidth, actualWidth, 'left') + } else { + this.replaceArrow(actualHeight - height, actualHeight, 'top') + } + + if (replace) $tip.offset(offset) + } + + Tooltip.prototype.replaceArrow = function (delta, dimension, position) { + this.arrow().css(position, delta ? (50 * (1 - delta / dimension) + '%') : '') + } + + Tooltip.prototype.setContent = function () { + var $tip = this.tip() + var title = this.getTitle() + + $tip.find('.tooltip-inner')[this.options.html ? 'html' : 'text'](title) + $tip.removeClass('fade in top bottom left right') + } + + Tooltip.prototype.hide = function () { + var that = this + var $tip = this.tip() + var e = $.Event('hide.bs.' + this.type) + + function complete() { + if (that.hoverState != 'in') $tip.detach() + that.$element.trigger('hidden.bs.' + that.type) + } + + this.$element.trigger(e) + + if (e.isDefaultPrevented()) return + + $tip.removeClass('in') + + $.support.transition && this.$tip.hasClass('fade') ? + $tip + .one($.support.transition.end, complete) + .emulateTransitionEnd(150) : + complete() + + this.hoverState = null + + return this + } + + Tooltip.prototype.fixTitle = function () { + var $e = this.$element + if ($e.attr('title') || typeof($e.attr('data-original-title')) != 'string') { + $e.attr('data-original-title', $e.attr('title') || '').attr('title', '') + } + } + + Tooltip.prototype.hasContent = function () { + return this.getTitle() + } + + Tooltip.prototype.getPosition = function () { + var el = this.$element[0] + return $.extend({}, (typeof el.getBoundingClientRect == 'function') ? el.getBoundingClientRect() : { + width: el.offsetWidth, + height: el.offsetHeight + }, this.$element.offset()) + } + + Tooltip.prototype.getCalculatedOffset = function (placement, pos, actualWidth, actualHeight) { + return placement == 'bottom' ? { top: pos.top + pos.height, left: pos.left + pos.width / 2 - actualWidth / 2 } : + placement == 'top' ? { top: pos.top - actualHeight, left: pos.left + pos.width / 2 - actualWidth / 2 } : + placement == 'left' ? { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth } : + /* placement == 'right' */ { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width } + } + + Tooltip.prototype.getTitle = function () { + var title + var $e = this.$element + var o = this.options + + title = $e.attr('data-original-title') + || (typeof o.title == 'function' ? o.title.call($e[0]) : o.title) + + return title + } + + Tooltip.prototype.tip = function () { + return this.$tip = this.$tip || $(this.options.template) + } + + Tooltip.prototype.arrow = function () { + return this.$arrow = this.$arrow || this.tip().find('.tooltip-arrow') + } + + Tooltip.prototype.validate = function () { + if (!this.$element[0].parentNode) { + this.hide() + this.$element = null + this.options = null + } + } + + Tooltip.prototype.enable = function () { + this.enabled = true + } + + Tooltip.prototype.disable = function () { + this.enabled = false + } + + Tooltip.prototype.toggleEnabled = function () { + this.enabled = !this.enabled + } + + Tooltip.prototype.toggle = function (e) { + var self = e ? $(e.currentTarget)[this.type](this.getDelegateOptions()).data('bs.' + this.type) : this + self.tip().hasClass('in') ? self.leave(self) : self.enter(self) + } + + Tooltip.prototype.destroy = function () { + clearTimeout(this.timeout) + this.hide().$element.off('.' + this.type).removeData('bs.' + this.type) + } + + + // TOOLTIP PLUGIN DEFINITION + // ========================= + + var old = $.fn.tooltip + + $.fn.tooltip = function (option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.tooltip') + var options = typeof option == 'object' && option + + if (!data && option == 'destroy') return + if (!data) $this.data('bs.tooltip', (data = new Tooltip(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + $.fn.tooltip.Constructor = Tooltip + + + // TOOLTIP NO CONFLICT + // =================== + + $.fn.tooltip.noConflict = function () { + $.fn.tooltip = old + return this + } + +}(jQuery); + +/* ======================================================================== + * Bootstrap: popover.js v3.1.1 + * http://getbootstrap.com/javascript/#popovers + * ======================================================================== + * Copyright 2011-2014 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // POPOVER PUBLIC CLASS DEFINITION + // =============================== + + var Popover = function (element, options) { + this.init('popover', element, options) + } + + if (!$.fn.tooltip) throw new Error('Popover requires tooltip.js') + + Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, { + placement: 'right', + trigger: 'click', + content: '', + template: '

' + }) + + + // NOTE: POPOVER EXTENDS tooltip.js + // ================================ + + Popover.prototype = $.extend({}, $.fn.tooltip.Constructor.prototype) + + Popover.prototype.constructor = Popover + + Popover.prototype.getDefaults = function () { + return Popover.DEFAULTS + } + + Popover.prototype.setContent = function () { + var $tip = this.tip() + var title = this.getTitle() + var content = this.getContent() + + $tip.find('.popover-title')[this.options.html ? 'html' : 'text'](title) + $tip.find('.popover-content')[ // we use append for html objects to maintain js events + this.options.html ? (typeof content == 'string' ? 'html' : 'append') : 'text' + ](content) + + $tip.removeClass('fade top bottom left right in') + + // IE8 doesn't accept hiding via the `:empty` pseudo selector, we have to do + // this manually by checking the contents. + if (!$tip.find('.popover-title').html()) $tip.find('.popover-title').hide() + } + + Popover.prototype.hasContent = function () { + return this.getTitle() || this.getContent() + } + + Popover.prototype.getContent = function () { + var $e = this.$element + var o = this.options + + return $e.attr('data-content') + || (typeof o.content == 'function' ? + o.content.call($e[0]) : + o.content) + } + + Popover.prototype.arrow = function () { + return this.$arrow = this.$arrow || this.tip().find('.arrow') + } + + Popover.prototype.tip = function () { + if (!this.$tip) this.$tip = $(this.options.template) + return this.$tip + } + + + // POPOVER PLUGIN DEFINITION + // ========================= + + var old = $.fn.popover + + $.fn.popover = function (option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.popover') + var options = typeof option == 'object' && option + + if (!data && option == 'destroy') return + if (!data) $this.data('bs.popover', (data = new Popover(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + $.fn.popover.Constructor = Popover + + + // POPOVER NO CONFLICT + // =================== + + $.fn.popover.noConflict = function () { + $.fn.popover = old + return this + } + +}(jQuery); + +(function($, window) { + var Tour, document; + document = window.document; + Tour = (function() { + function Tour(options) { + var storage; + try { + storage = window.localStorage; + } catch (_error) { + storage = false; + } + this._options = $.extend({ + name: "tour", + steps: [], + container: "body", + keyboard: true, + storage: storage, + debug: false, + backdrop: false, + redirect: true, + orphan: false, + duration: false, + basePath: "", + template: "

", + afterSetState: function(key, value) {}, + afterGetState: function(key, value) {}, + afterRemoveState: function(key) {}, + onStart: function(tour) {}, + onEnd: function(tour) {}, + onShow: function(tour) {}, + onShown: function(tour) {}, + onHide: function(tour) {}, + onHidden: function(tour) {}, + onNext: function(tour) {}, + onPrev: function(tour) {}, + onPause: function(tour, duration) {}, + onResume: function(tour, duration) {} + }, options); + this._force = false; + this._inited = false; + this.backdrop = { + overlay: null, + $element: null, + $background: null, + backgroundShown: false, + overlayElementShown: false + }; + this; + } + + Tour.prototype.addSteps = function(steps) { + var step, _i, _len; + for (_i = 0, _len = steps.length; _i < _len; _i++) { + step = steps[_i]; + this.addStep(step); + } + return this; + }; + + Tour.prototype.addStep = function(step) { + this._options.steps.push(step); + return this; + }; + + Tour.prototype.getStep = function(i) { + if (this._options.steps[i] != null) { + return $.extend({ + id: "step-" + i, + path: "", + placement: "right", + title: "", + content: "

", + next: i === this._options.steps.length - 1 ? -1 : i + 1, + prev: i - 1, + animation: true, + container: this._options.container, + backdrop: this._options.backdrop, + redirect: this._options.redirect, + orphan: this._options.orphan, + duration: this._options.duration, + template: this._options.template, + onShow: this._options.onShow, + onShown: this._options.onShown, + onHide: this._options.onHide, + onHidden: this._options.onHidden, + onNext: this._options.onNext, + onPrev: this._options.onPrev, + onPause: this._options.onPause, + onResume: this._options.onResume + }, this._options.steps[i]); + } + }; + + Tour.prototype.init = function(force) { + this._force = force; + if (this.ended()) { + this._debug("Tour ended, init prevented."); + return this; + } + this.setCurrentStep(); + this._initMouseNavigation(); + this._initKeyboardNavigation(); + this._onResize((function(_this) { + return function() { + return _this.showStep(_this._current); + }; + })(this)); + if (this._current !== null) { + this.showStep(this._current); + } + this._inited = true; + return this; + }; + + Tour.prototype.start = function(force) { + var promise; + if (force == null) { + force = false; + } + if (!this._inited) { + this.init(force); + } + if (this._current === null) { + promise = this._makePromise(this._options.onStart != null ? this._options.onStart(this) : void 0); + this._callOnPromiseDone(promise, this.showStep, 0); + } + return this; + }; + + Tour.prototype.next = function() { + var promise; + promise = this.hideStep(this._current); + return this._callOnPromiseDone(promise, this._showNextStep); + }; + + Tour.prototype.prev = function() { + var promise; + promise = this.hideStep(this._current); + return this._callOnPromiseDone(promise, this._showPrevStep); + }; + + Tour.prototype.goTo = function(i) { + var promise; + promise = this.hideStep(this._current); + return this._callOnPromiseDone(promise, this.showStep, i); + }; + + Tour.prototype.end = function() { + var endHelper, promise; + endHelper = (function(_this) { + return function(e) { + $(document).off("click.tour-" + _this._options.name); + $(document).off("keyup.tour-" + _this._options.name); + $(window).off("resize.tour-" + _this._options.name); + _this._setState("end", "yes"); + _this._inited = false; + _this._force = false; + _this._clearTimer(); + if (_this._options.onEnd != null) { + return _this._options.onEnd(_this); + } + }; + })(this); + promise = this.hideStep(this._current); + return this._callOnPromiseDone(promise, endHelper); + }; + + Tour.prototype.ended = function() { + return !this._force && !!this._getState("end"); + }; + + Tour.prototype.restart = function() { + this._removeState("current_step"); + this._removeState("end"); + return this.start(); + }; + + Tour.prototype.pause = function() { + var step; + step = this.getStep(this._current); + if (!(step && step.duration)) { + return this; + } + this._paused = true; + this._duration -= new Date().getTime() - this._start; + window.clearTimeout(this._timer); + this._debug("Paused/Stopped step " + (this._current + 1) + " timer (" + this._duration + " remaining)."); + if (step.onPause != null) { + return step.onPause(this, this._duration); + } + }; + + Tour.prototype.resume = function() { + var step; + step = this.getStep(this._current); + if (!(step && step.duration)) { + return this; + } + this._paused = false; + this._start = new Date().getTime(); + this._duration = this._duration || step.duration; + this._timer = window.setTimeout((function(_this) { + return function() { + if (_this._isLast()) { + return _this.next(); + } else { + return _this.end(); + } + }; + })(this), this._duration); + this._debug("Started step " + (this._current + 1) + " timer with duration " + this._duration); + if ((step.onResume != null) && this._duration !== step.duration) { + return step.onResume(this, this._duration); + } + }; + + Tour.prototype.hideStep = function(i) { + var hideStepHelper, promise, step; + step = this.getStep(i); + if (!step) { + return; + } + this._clearTimer(); + promise = this._makePromise(step.onHide != null ? step.onHide(this, i) : void 0); + hideStepHelper = (function(_this) { + return function(e) { + var $element; + $element = $(step.element); + if (!($element.data("bs.popover") || $element.data("popover"))) { + $element = $("body"); + } + $element.popover("destroy").removeClass("tour-" + _this._options.name + "-element tour-" + _this._options.name + "-" + i + "-element"); + if (step.reflex) { + $element.css("cursor", "").off("click.tour-" + _this._options.name); + } + if (step.backdrop) { + _this._hideBackdrop(); + } + if (step.onHidden != null) { + return step.onHidden(_this); + } + }; + })(this); + this._callOnPromiseDone(promise, hideStepHelper); + return promise; + }; + + Tour.prototype.showStep = function(i) { + var promise, showStepHelper, skipToPrevious, step; + if (this.ended()) { + this._debug("Tour ended, showStep prevented."); + return this; + } + step = this.getStep(i); + if (!step) { + return; + } + skipToPrevious = i < this._current; + promise = this._makePromise(step.onShow != null ? step.onShow(this, i) : void 0); + showStepHelper = (function(_this) { + return function(e) { + var current_path, path; + _this.setCurrentStep(i); + path = (function() { + switch ({}.toString.call(step.path)) { + case "[object Function]": + return step.path(); + case "[object String]": + return this._options.basePath + step.path; + default: + return step.path; + } + }).call(_this); + current_path = [document.location.pathname, document.location.hash].join(""); + if (_this._isRedirect(path, current_path)) { + _this._redirect(step, path); + return; + } + if (_this._isOrphan(step)) { + if (!step.orphan) { + _this._debug("Skip the orphan step " + (_this._current + 1) + ". Orphan option is false and the element doesn't exist or is hidden."); + if (skipToPrevious) { + _this._showPrevStep(); + } else { + _this._showNextStep(); + } + return; + } + _this._debug("Show the orphan step " + (_this._current + 1) + ". Orphans option is true."); + } + if (step.backdrop) { + _this._showBackdrop(!_this._isOrphan(step) ? step.element : void 0); + } + _this._scrollIntoView(step.element, function() { + if (_this.getCurrentStep() !== i) { + return; + } + if ((step.element != null) && step.backdrop) { + _this._showOverlayElement(step.element); + } + _this._showPopover(step, i); + if (step.onShown != null) { + step.onShown(_this); + } + return _this._debug("Step " + (_this._current + 1) + " of " + _this._options.steps.length); + }); + if (step.duration) { + return _this.resume(); + } + }; + })(this); + this._callOnPromiseDone(promise, showStepHelper); + return promise; + }; + + Tour.prototype.getCurrentStep = function() { + return this._current; + }; + + Tour.prototype.setCurrentStep = function(value) { + if (value != null) { + this._current = value; + this._setState("current_step", value); + } else { + this._current = this._getState("current_step"); + this._current = this._current === null ? null : parseInt(this._current, 10); + } + return this; + }; + + Tour.prototype._setState = function(key, value) { + var e, keyName; + if (this._options.storage) { + keyName = "" + this._options.name + "_" + key; + try { + this._options.storage.setItem(keyName, value); + } catch (_error) { + e = _error; + if (e.code === DOMException.QUOTA_EXCEEDED_ERR) { + this.debug("LocalStorage quota exceeded. State storage failed."); + } + } + return this._options.afterSetState(keyName, value); + } else { + if (this._state == null) { + this._state = {}; + } + return this._state[key] = value; + } + }; + + Tour.prototype._removeState = function(key) { + var keyName; + if (this._options.storage) { + keyName = "" + this._options.name + "_" + key; + this._options.storage.removeItem(keyName); + return this._options.afterRemoveState(keyName); + } else { + if (this._state != null) { + return delete this._state[key]; + } + } + }; + + Tour.prototype._getState = function(key) { + var keyName, value; + if (this._options.storage) { + keyName = "" + this._options.name + "_" + key; + value = this._options.storage.getItem(keyName); + } else { + if (this._state != null) { + value = this._state[key]; + } + } + if (value === void 0 || value === "null") { + value = null; + } + this._options.afterGetState(key, value); + return value; + }; + + Tour.prototype._showNextStep = function() { + var promise, showNextStepHelper, step; + step = this.getStep(this._current); + showNextStepHelper = (function(_this) { + return function(e) { + return _this.showStep(step.next); + }; + })(this); + promise = this._makePromise(step.onNext != null ? step.onNext(this) : void 0); + return this._callOnPromiseDone(promise, showNextStepHelper); + }; + + Tour.prototype._showPrevStep = function() { + var promise, showPrevStepHelper, step; + step = this.getStep(this._current); + showPrevStepHelper = (function(_this) { + return function(e) { + return _this.showStep(step.prev); + }; + })(this); + promise = this._makePromise(step.onPrev != null ? step.onPrev(this) : void 0); + return this._callOnPromiseDone(promise, showPrevStepHelper); + }; + + Tour.prototype._debug = function(text) { + if (this._options.debug) { + return window.console.log("Bootstrap Tour '" + this._options.name + "' | " + text); + } + }; + + Tour.prototype._isRedirect = function(path, currentPath) { + return (path != null) && path !== "" && (({}.toString.call(path) === "[object RegExp]" && !path.test(currentPath)) || ({}.toString.call(path) === "[object String]" && path.replace(/\?.*$/, "").replace(/\/?$/, "") !== currentPath.replace(/\/?$/, ""))); + }; + + Tour.prototype._redirect = function(step, path) { + if ($.isFunction(step.redirect)) { + return step.redirect.call(this, path); + } else if (step.redirect === true) { + this._debug("Redirect to " + path); + return document.location.href = path; + } + }; + + Tour.prototype._isOrphan = function(step) { + return (step.element == null) || !$(step.element).length || $(step.element).is(":hidden") && ($(step.element)[0].namespaceURI !== "http://www.w3.org/2000/svg"); + }; + + Tour.prototype._isLast = function() { + return this._current < this._options.steps.length - 1; + }; + + Tour.prototype._showPopover = function(step, i) { + var $element, $navigation, $template, $tip, isOrphan, options; + $(".tour-" + this._options.name).remove(); + options = $.extend({}, this._options); + $template = $.isFunction(step.template) ? $(step.template(i, step)) : $(step.template); + $navigation = $template.find(".popover-navigation"); + isOrphan = this._isOrphan(step); + if (isOrphan) { + step.element = "body"; + step.placement = "top"; + $template = $template.addClass("orphan"); + } + $element = $(step.element); + $template.addClass("tour-" + this._options.name + " tour-" + this._options.name + "-" + i); + $element.addClass("tour-" + this._options.name + "-element tour-" + this._options.name + "-" + i + "-element"); + if (step.options) { + $.extend(options, step.options); + } + if (step.reflex && !isOrphan) { + $element.css("cursor", "pointer").on("click.tour-" + this._options.name, (function(_this) { + return function() { + if (_this._isLast()) { + return _this.next(); + } else { + return _this.end(); + } + }; + })(this)); + } + if (step.prev < 0) { + $navigation.find("[data-role='prev']").addClass("disabled"); + } + if (step.next < 0) { + $navigation.find("[data-role='next']").addClass("disabled"); + } + if (!step.duration) { + $navigation.find("[data-role='pause-resume']").remove(); + } + step.template = $template.clone().wrap("
").parent().html(); + $element.popover({ + placement: step.placement, + trigger: "manual", + title: step.title, + content: step.content, + html: true, + animation: step.animation, + container: step.container, + template: step.template, + selector: step.element + }).popover("show"); + $tip = $element.data("bs.popover") ? $element.data("bs.popover").tip() : $element.data("popover").tip(); + $tip.attr("id", step.id); + this._reposition($tip, step); + if (isOrphan) { + return this._center($tip); + } + }; + + Tour.prototype._reposition = function($tip, step) { + var offsetBottom, offsetHeight, offsetRight, offsetWidth, originalLeft, originalTop, tipOffset; + offsetWidth = $tip[0].offsetWidth; + offsetHeight = $tip[0].offsetHeight; + tipOffset = $tip.offset(); + originalLeft = tipOffset.left; + originalTop = tipOffset.top; + offsetBottom = $(document).outerHeight() - tipOffset.top - $tip.outerHeight(); + if (offsetBottom < 0) { + tipOffset.top = tipOffset.top + offsetBottom; + } + offsetRight = $("html").outerWidth() - tipOffset.left - $tip.outerWidth(); + if (offsetRight < 0) { + tipOffset.left = tipOffset.left + offsetRight; + } + if (tipOffset.top < 0) { + tipOffset.top = 0; + } + if (tipOffset.left < 0) { + tipOffset.left = 0; + } + $tip.offset(tipOffset); + if (step.placement === "bottom" || step.placement === "top") { + if (originalLeft !== tipOffset.left) { + return this._replaceArrow($tip, (tipOffset.left - originalLeft) * 2, offsetWidth, "left"); + } + } else { + if (originalTop !== tipOffset.top) { + return this._replaceArrow($tip, (tipOffset.top - originalTop) * 2, offsetHeight, "top"); + } + } + }; + + Tour.prototype._center = function($tip) { + return $tip.css("top", $(window).outerHeight() / 2 - $tip.outerHeight() / 2); + }; + + Tour.prototype._replaceArrow = function($tip, delta, dimension, position) { + return $tip.find(".arrow").css(position, delta ? 50 * (1 - delta / dimension) + "%" : ""); + }; + + Tour.prototype._scrollIntoView = function(element, callback) { + var $element, $window, counter, offsetTop, scrollTop, windowHeight; + $element = $(element); + if (!$element.length) { + return callback(); + } + $window = $(window); + offsetTop = $element.offset().top; + windowHeight = $window.height(); + scrollTop = Math.max(0, offsetTop - (windowHeight / 2)); + this._debug("Scroll into view. ScrollTop: " + scrollTop + ". Element offset: " + offsetTop + ". Window height: " + windowHeight + "."); + counter = 0; + return $("body,html").stop(true, true).animate({ + scrollTop: Math.ceil(scrollTop) + }, (function(_this) { + return function() { + if (++counter === 2) { + callback(); + return _this._debug("Scroll into view. Animation end element offset: " + ($element.offset().top) + ". Window height: " + ($window.height()) + "."); + } + }; + })(this)); + }; + + Tour.prototype._onResize = function(callback, timeout) { + return $(window).on("resize.tour-" + this._options.name, function() { + clearTimeout(timeout); + return timeout = setTimeout(callback, 100); + }); + }; + + Tour.prototype._initMouseNavigation = function() { + var _this; + _this = this; + return $(document).off("click.tour-" + this._options.name, ".popover.tour-" + this._options.name + " *[data-role='prev']:not(.disabled)").off("click.tour-" + this._options.name, ".popover.tour-" + this._options.name + " *[data-role='next']:not(.disabled)").off("click.tour-" + this._options.name, ".popover.tour-" + this._options.name + " *[data-role='end']").off("click.tour-" + this._options.name, ".popover.tour-" + this._options.name + " *[data-role='pause-resume']").on("click.tour-" + this._options.name, ".popover.tour-" + this._options.name + " *[data-role='next']:not(.disabled)", (function(_this) { + return function(e) { + e.preventDefault(); + return _this.next(); + }; + })(this)).on("click.tour-" + this._options.name, ".popover.tour-" + this._options.name + " *[data-role='prev']:not(.disabled)", (function(_this) { + return function(e) { + e.preventDefault(); + return _this.prev(); + }; + })(this)).on("click.tour-" + this._options.name, ".popover.tour-" + this._options.name + " *[data-role='end']", (function(_this) { + return function(e) { + e.preventDefault(); + return _this.end(); + }; + })(this)).on("click.tour-" + this._options.name, ".popover.tour-" + this._options.name + " *[data-role='pause-resume']", function(e) { + var $this; + e.preventDefault(); + $this = $(this); + $this.text(_this._paused ? $this.data("pause-text") : $this.data("resume-text")); + if (_this._paused) { + return _this.resume(); + } else { + return _this.pause(); + } + }); + }; + + Tour.prototype._initKeyboardNavigation = function() { + if (!this._options.keyboard) { + return; + } + return $(document).on("keyup.tour-" + this._options.name, (function(_this) { + return function(e) { + if (!e.which) { + return; + } + switch (e.which) { + case 39: + e.preventDefault(); + if (_this._isLast()) { + return _this.next(); + } else { + return _this.end(); + } + break; + case 37: + e.preventDefault(); + if (_this._current > 0) { + return _this.prev(); + } + break; + case 27: + e.preventDefault(); + return _this.end(); + } + }; + })(this)); + }; + + Tour.prototype._makePromise = function(result) { + if (result && $.isFunction(result.then)) { + return result; + } else { + return null; + } + }; + + Tour.prototype._callOnPromiseDone = function(promise, cb, arg) { + if (promise) { + return promise.then((function(_this) { + return function(e) { + return cb.call(_this, arg); + }; + })(this)); + } else { + return cb.call(this, arg); + } + }; + + Tour.prototype._showBackdrop = function(element) { + if (this.backdrop.backgroundShown) { + return; + } + this.backdrop = $("
", { + "class": "tour-backdrop" + }); + this.backdrop.backgroundShown = true; + return $("body").append(this.backdrop); + }; + + Tour.prototype._hideBackdrop = function() { + this._hideOverlayElement(); + return this._hideBackground(); + }; + + Tour.prototype._hideBackground = function() { + if (this.backdrop) { + this.backdrop.remove(); + this.backdrop.overlay = null; + return this.backdrop.backgroundShown = false; + } + }; + + Tour.prototype._showOverlayElement = function(element) { + var $background, $element, offset; + $element = $(element); + if (!$element || $element.length === 0 || this.backdrop.overlayElementShown) { + return; + } + this.backdrop.overlayElementShown = true; + $background = $("
"); + offset = $element.offset(); + offset.top = offset.top; + offset.left = offset.left; + $background.width($element.innerWidth()).height($element.innerHeight()).addClass("tour-step-background").offset(offset); + $element.addClass("tour-step-backdrop"); + $("body").append($background); + this.backdrop.$element = $element; + return this.backdrop.$background = $background; + }; + + Tour.prototype._hideOverlayElement = function() { + if (!this.backdrop.overlayElementShown) { + return; + } + this.backdrop.$element.removeClass("tour-step-backdrop"); + this.backdrop.$background.remove(); + this.backdrop.$element = null; + this.backdrop.$background = null; + return this.backdrop.overlayElementShown = false; + }; + + Tour.prototype._clearTimer = function() { + window.clearTimeout(this._timer); + this._timer = null; + return this._duration = null; + }; + + return Tour; + + })(); + return window.Tour = Tour; +})(jQuery, window); diff --git a/app/javascript/legacy/common/vendor/bootstrap.js b/app/javascript/legacy/common/vendor/bootstrap.js new file mode 100644 index 00000000..8779549c --- /dev/null +++ b/app/javascript/legacy/common/vendor/bootstrap.js @@ -0,0 +1,334 @@ +// License: LGPL-3.0-or-later +/*! + * Bootstrap v3.3.2 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ + +/*! + * Generated using the Bootstrap Customizer (http://getbootstrap.com/customize/?id=0d2d6ea77a31113c4876) + * Config saved to config.json and https://gist.github.com/0d2d6ea77a31113c4876 + */ +if (typeof jQuery === 'undefined') { + throw new Error('Bootstrap\'s JavaScript requires jQuery') +} ++function ($) { + 'use strict'; + var version = $.fn.jquery.split(' ')[0].split('.') + if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1)) { + throw new Error('Bootstrap\'s JavaScript requires jQuery version 1.9.1 or higher') + } +}(jQuery); + +/* ======================================================================== + * Bootstrap: carousel.js v3.3.2 + * http://getbootstrap.com/javascript/#carousel + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // CAROUSEL CLASS DEFINITION + // ========================= + + var Carousel = function (element, options) { + this.$element = $(element) + this.$indicators = this.$element.find('.carousel-indicators') + this.options = options + this.paused = + this.sliding = + this.interval = + this.$active = + this.$items = null + + this.options.keyboard && this.$element.on('keydown.bs.carousel', $.proxy(this.keydown, this)) + + this.options.pause == 'hover' && !('ontouchstart' in document.documentElement) && this.$element + .on('mouseenter.bs.carousel', $.proxy(this.pause, this)) + .on('mouseleave.bs.carousel', $.proxy(this.cycle, this)) + } + + Carousel.VERSION = '3.3.2' + + Carousel.TRANSITION_DURATION = 600 + + Carousel.DEFAULTS = { + interval: 5000, + pause: 'hover', + wrap: true, + keyboard: true + } + + Carousel.prototype.keydown = function (e) { + if (/input|textarea/i.test(e.target.tagName)) return + switch (e.which) { + case 37: this.prev(); break + case 39: this.next(); break + default: return + } + + e.preventDefault() + } + + Carousel.prototype.cycle = function (e) { + e || (this.paused = false) + + this.interval && clearInterval(this.interval) + + this.options.interval + && !this.paused + && (this.interval = setInterval($.proxy(this.next, this), this.options.interval)) + + return this + } + + Carousel.prototype.getItemIndex = function (item) { + this.$items = item.parent().children('.item') + return this.$items.index(item || this.$active) + } + + Carousel.prototype.getItemForDirection = function (direction, active) { + var activeIndex = this.getItemIndex(active) + var willWrap = (direction == 'prev' && activeIndex === 0) + || (direction == 'next' && activeIndex == (this.$items.length - 1)) + if (willWrap && !this.options.wrap) return active + var delta = direction == 'prev' ? -1 : 1 + var itemIndex = (activeIndex + delta) % this.$items.length + return this.$items.eq(itemIndex) + } + + Carousel.prototype.to = function (pos) { + var that = this + var activeIndex = this.getItemIndex(this.$active = this.$element.find('.item.active')) + + if (pos > (this.$items.length - 1) || pos < 0) return + + if (this.sliding) return this.$element.one('slid.bs.carousel', function () { that.to(pos) }) // yes, "slid" + if (activeIndex == pos) return this.pause().cycle() + + return this.slide(pos > activeIndex ? 'next' : 'prev', this.$items.eq(pos)) + } + + Carousel.prototype.pause = function (e) { + e || (this.paused = true) + + if (this.$element.find('.next, .prev').length && $.support.transition) { + this.$element.trigger($.support.transition.end) + this.cycle(true) + } + + this.interval = clearInterval(this.interval) + + return this + } + + Carousel.prototype.next = function () { + if (this.sliding) return + return this.slide('next') + } + + Carousel.prototype.prev = function () { + if (this.sliding) return + return this.slide('prev') + } + + Carousel.prototype.slide = function (type, next) { + var $active = this.$element.find('.item.active') + var $next = next || this.getItemForDirection(type, $active) + var isCycling = this.interval + var direction = type == 'next' ? 'left' : 'right' + var that = this + + if ($next.hasClass('active')) return (this.sliding = false) + + var relatedTarget = $next[0] + var slideEvent = $.Event('slide.bs.carousel', { + relatedTarget: relatedTarget, + direction: direction + }) + this.$element.trigger(slideEvent) + if (slideEvent.isDefaultPrevented()) return + + this.sliding = true + + isCycling && this.pause() + + if (this.$indicators.length) { + this.$indicators.find('.active').removeClass('active') + var $nextIndicator = $(this.$indicators.children()[this.getItemIndex($next)]) + $nextIndicator && $nextIndicator.addClass('active') + } + + var slidEvent = $.Event('slid.bs.carousel', { relatedTarget: relatedTarget, direction: direction }) // yes, "slid" + if ($.support.transition && this.$element.hasClass('slide')) { + $next.addClass(type) + $next[0].offsetWidth // force reflow + $active.addClass(direction) + $next.addClass(direction) + $active + .one('bsTransitionEnd', function () { + $next.removeClass([type, direction].join(' ')).addClass('active') + $active.removeClass(['active', direction].join(' ')) + that.sliding = false + setTimeout(function () { + that.$element.trigger(slidEvent) + }, 0) + }) + .emulateTransitionEnd(Carousel.TRANSITION_DURATION) + } else { + $active.removeClass('active') + $next.addClass('active') + this.sliding = false + this.$element.trigger(slidEvent) + } + + isCycling && this.cycle() + + return this + } + + + // CAROUSEL PLUGIN DEFINITION + // ========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.carousel') + var options = $.extend({}, Carousel.DEFAULTS, $this.data(), typeof option == 'object' && option) + var action = typeof option == 'string' ? option : options.slide + + if (!data) $this.data('bs.carousel', (data = new Carousel(this, options))) + if (typeof option == 'number') data.to(option) + else if (action) data[action]() + else if (options.interval) data.pause().cycle() + }) + } + + var old = $.fn.carousel + + $.fn.carousel = Plugin + $.fn.carousel.Constructor = Carousel + + + // CAROUSEL NO CONFLICT + // ==================== + + $.fn.carousel.noConflict = function () { + $.fn.carousel = old + return this + } + + + // CAROUSEL DATA-API + // ================= + + var clickHandler = function (e) { + var href + var $this = $(this) + var $target = $($this.attr('data-target') || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '')) // strip for ie7 + if (!$target.hasClass('carousel')) return + var options = $.extend({}, $target.data(), $this.data()) + var slideIndex = $this.attr('data-slide-to') + if (slideIndex) options.interval = false + + Plugin.call($target, options) + + if (slideIndex) { + $target.data('bs.carousel').to(slideIndex) + } + + e.preventDefault() + } + + $(document) + .on('click.bs.carousel.data-api', '[data-slide]', clickHandler) + .on('click.bs.carousel.data-api', '[data-slide-to]', clickHandler) + + $(window).on('load', function () { + $('[data-ride="carousel"]').each(function () { + var $carousel = $(this) + Plugin.call($carousel, $carousel.data()) + }) + }) + +}(jQuery); + + +/* ======================================================================== + * Bootstrap: transition.js v3.3.2 + * http://getbootstrap.com/javascript/#transitions + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // CSS TRANSITION SUPPORT (Shoutout: http://www.modernizr.com/) + // ============================================================ + + function transitionEnd() { + var el = document.createElement('bootstrap') + + var transEndEventNames = { + WebkitTransition : 'webkitTransitionEnd', + MozTransition : 'transitionend', + OTransition : 'oTransitionEnd otransitionend', + transition : 'transitionend' + } + + for (var name in transEndEventNames) { + if (el.style[name] !== undefined) { + return { end: transEndEventNames[name] } + } + } + + return false // explicit for ie8 ( ._.) + } + + // http://blog.alexmaccaw.com/css-transitions + $.fn.emulateTransitionEnd = function (duration) { + var called = false + var $el = this + $(this).one('bsTransitionEnd', function () { called = true }) + var callback = function () { if (!called) $($el).trigger($.support.transition.end) } + setTimeout(callback, duration) + return this + } + + $(function () { + $.support.transition = transitionEnd() + + if (!$.support.transition) return + + $.event.special.bsTransitionEnd = { + bindType: $.support.transition.end, + delegateType: $.support.transition.end, + handle: function (e) { + if ($(e.target).is(this)) return e.handleObj.handler.apply(this, arguments) + } + } + }) + +}(jQuery); + + + + + +$(document).ready(function() { + setTimeout(function(){ + var $progressBar = $('#progress') + var width = $progressBar.attr('data-perc') + $progressBar.css('width', width + '%') + }, 1000) + }) + + diff --git a/app/javascript/legacy/common/vendor/colpick.js b/app/javascript/legacy/common/vendor/colpick.js new file mode 100644 index 00000000..da3ed7c5 --- /dev/null +++ b/app/javascript/legacy/common/vendor/colpick.js @@ -0,0 +1,517 @@ +// License: LGPL-3.0-or-later +/* +colpick Color Picker +Copyright 2013 Jose Vargas. Licensed under GPL license. Based on Stefan Petre's Color Picker www.eyecon.ro, dual licensed under the MIT and GPL licenses + +For usage and examples: colpick.com/plugin + */ + +var colpick = function () { + var + tpl = '
#
R
G
B
H
S
B
', + defaults = { + showEvent: 'click', + onShow: function () {}, + onBeforeShow: function(){}, + onHide: function () {}, + onChange: function () {}, + onSubmit: function () {}, + colorScheme: 'light', + color: '3289c7', + livePreview: true, + flat: false, + layout: 'full', + submit: 1, + submitText: 'OK', + height: 156 + }, + //Fill the inputs of the plugin + fillRGBFields = function (hsb, cal) { + var rgb = hsbToRgb(hsb); + $(cal).data('colpick').fields + .eq(1).val(rgb.r).end() + .eq(2).val(rgb.g).end() + .eq(3).val(rgb.b).end(); + }, + fillHSBFields = function (hsb, cal) { + $(cal).data('colpick').fields + .eq(4).val(Math.round(hsb.h)).end() + .eq(5).val(Math.round(hsb.s)).end() + .eq(6).val(Math.round(hsb.b)).end(); + }, + fillHexFields = function (hsb, cal) { + $(cal).data('colpick').fields.eq(0).val(hsbToHex(hsb)); + }, + //Set the round selector position + setSelector = function (hsb, cal) { + $(cal).data('colpick').selector.css('backgroundColor', '#' + hsbToHex({h: hsb.h, s: 100, b: 100})); + $(cal).data('colpick').selectorIndic.css({ + left: parseInt($(cal).data('colpick').height * hsb.s/100, 10), + top: parseInt($(cal).data('colpick').height * (100-hsb.b)/100, 10) + }); + }, + //Set the hue selector position + setHue = function (hsb, cal) { + $(cal).data('colpick').hue.css('top', parseInt($(cal).data('colpick').height - $(cal).data('colpick').height * hsb.h/360, 10)); + }, + //Set current and new colors + setCurrentColor = function (hsb, cal) { + $(cal).data('colpick').currentColor.css('backgroundColor', '#' + hsbToHex(hsb)); + }, + setNewColor = function (hsb, cal) { + $(cal).data('colpick').newColor.css('backgroundColor', '#' + hsbToHex(hsb)); + }, + //Called when the new color is changed + change = function (ev) { + var cal = $(this).parent().parent(), col; + if (this.parentNode.className.indexOf('_hex') > 0) { + cal.data('colpick').color = col = hexToHsb(fixHex(this.value)); + fillRGBFields(col, cal.get(0)); + fillHSBFields(col, cal.get(0)); + } else if (this.parentNode.className.indexOf('_hsb') > 0) { + cal.data('colpick').color = col = fixHSB({ + h: parseInt(cal.data('colpick').fields.eq(4).val(), 10), + s: parseInt(cal.data('colpick').fields.eq(5).val(), 10), + b: parseInt(cal.data('colpick').fields.eq(6).val(), 10) + }); + fillRGBFields(col, cal.get(0)); + fillHexFields(col, cal.get(0)); + } else { + cal.data('colpick').color = col = rgbToHsb(fixRGB({ + r: parseInt(cal.data('colpick').fields.eq(1).val(), 10), + g: parseInt(cal.data('colpick').fields.eq(2).val(), 10), + b: parseInt(cal.data('colpick').fields.eq(3).val(), 10) + })); + fillHexFields(col, cal.get(0)); + fillHSBFields(col, cal.get(0)); + } + setSelector(col, cal.get(0)); + setHue(col, cal.get(0)); + setNewColor(col, cal.get(0)); + cal.data('colpick').onChange.apply(cal.parent(), [col, hsbToHex(col), hsbToRgb(col), cal.data('colpick').el, 0]); + }, + //Change style on blur and on focus of inputs + blur = function (ev) { + $(this).parent().removeClass('colpick_focus'); + }, + focus = function () { + $(this).parent().parent().data('colpick').fields.parent().removeClass('colpick_focus'); + $(this).parent().addClass('colpick_focus'); + }, + //Increment/decrement arrows functions + downIncrement = function (ev) { + ev.preventDefault ? ev.preventDefault() : ev.returnValue = false; + var field = $(this).parent().find('input').focus(); + var current = { + el: $(this).parent().addClass('colpick_slider'), + max: this.parentNode.className.indexOf('_hsb_h') > 0 ? 360 : (this.parentNode.className.indexOf('_hsb') > 0 ? 100 : 255), + y: ev.pageY, + field: field, + val: parseInt(field.val(), 10), + preview: $(this).parent().parent().data('colpick').livePreview + }; + $(document).mouseup(current, upIncrement); + $(document).mousemove(current, moveIncrement); + }, + moveIncrement = function (ev) { + ev.data.field.val(Math.max(0, Math.min(ev.data.max, parseInt(ev.data.val - ev.pageY + ev.data.y, 10)))); + if (ev.data.preview) { + change.apply(ev.data.field.get(0), [true]); + } + return false; + }, + upIncrement = function (ev) { + change.apply(ev.data.field.get(0), [true]); + ev.data.el.removeClass('colpick_slider').find('input').focus(); + $(document).off('mouseup', upIncrement); + $(document).off('mousemove', moveIncrement); + return false; + }, + //Hue slider functions + downHue = function (ev) { + ev.preventDefault ? ev.preventDefault() : ev.returnValue = false; + var current = { + cal: $(this).parent(), + y: $(this).offset().top + }; + $(document).on('mouseup touchend',current,upHue); + $(document).on('mousemove touchmove',current,moveHue); + + var pageY = ((ev.type == 'touchstart') ? ev.originalEvent.changedTouches[0].pageY : ev.pageY ); + change.apply( + current.cal.data('colpick') + .fields.eq(4).val(parseInt(360*(current.cal.data('colpick').height - (pageY - current.y))/current.cal.data('colpick').height, 10)) + .get(0), + [current.cal.data('colpick').livePreview] + ); + return false; + }, + moveHue = function (ev) { + var pageY = ((ev.type == 'touchmove') ? ev.originalEvent.changedTouches[0].pageY : ev.pageY ); + change.apply( + ev.data.cal.data('colpick') + .fields.eq(4).val(parseInt(360*(ev.data.cal.data('colpick').height - Math.max(0,Math.min(ev.data.cal.data('colpick').height,(pageY - ev.data.y))))/ev.data.cal.data('colpick').height, 10)) + .get(0), + [ev.data.preview] + ); + return false; + }, + upHue = function (ev) { + fillRGBFields(ev.data.cal.data('colpick').color, ev.data.cal.get(0)); + fillHexFields(ev.data.cal.data('colpick').color, ev.data.cal.get(0)); + $(document).off('mouseup touchend',upHue); + $(document).off('mousemove touchmove',moveHue); + return false; + }, + //Color selector functions + downSelector = function (ev) { + ev.preventDefault ? ev.preventDefault() : ev.returnValue = false; + var current = { + cal: $(this).parent(), + pos: $(this).offset() + }; + current.preview = current.cal.data('colpick').livePreview; + + $(document).on('mouseup touchend',current,upSelector); + $(document).on('mousemove touchmove',current,moveSelector); + + if(ev.type == 'touchstart') { + var pageX = ev.originalEvent.changedTouches[0].pageX; + var pageY = ev.originalEvent.changedTouches[0].pageY; + } else { + var pageX = ev.pageX; + var pageY = ev.pageY; + } + + change.apply( + current.cal.data('colpick').fields + .eq(6).val(parseInt(100*(current.cal.data('colpick').height - (pageY - current.pos.top))/current.cal.data('colpick').height, 10)).end() + .eq(5).val(parseInt(100*(pageX - current.pos.left)/current.cal.data('colpick').height, 10)) + .get(0), + [current.preview] + ); + return false; + }, + moveSelector = function (ev) { + if(ev.type == 'touchmove') { + var pageX = ev.originalEvent.changedTouches[0].pageX; + var pageY = ev.originalEvent.changedTouches[0].pageY; + } else { + var pageX = ev.pageX; + var pageY = ev.pageY; + } + + change.apply( + ev.data.cal.data('colpick').fields + .eq(6).val(parseInt(100*(ev.data.cal.data('colpick').height - Math.max(0,Math.min(ev.data.cal.data('colpick').height,(pageY - ev.data.pos.top))))/ev.data.cal.data('colpick').height, 10)).end() + .eq(5).val(parseInt(100*(Math.max(0,Math.min(ev.data.cal.data('colpick').height,(pageX - ev.data.pos.left))))/ev.data.cal.data('colpick').height, 10)) + .get(0), + [ev.data.preview] + ); + return false; + }, + upSelector = function (ev) { + fillRGBFields(ev.data.cal.data('colpick').color, ev.data.cal.get(0)); + fillHexFields(ev.data.cal.data('colpick').color, ev.data.cal.get(0)); + $(document).off('mouseup touchend',upSelector); + $(document).off('mousemove touchmove',moveSelector); + return false; + }, + //Submit button + clickSubmit = function (ev) { + var cal = $(this).parent(); + var col = cal.data('colpick').color; + cal.data('colpick').origColor = col; + setCurrentColor(col, cal.get(0)); + cal.data('colpick').onSubmit(col, hsbToHex(col), hsbToRgb(col), cal.data('colpick').el); + }, + //Show/hide the color picker + show = function (ev) { + // Prevent the trigger of any direct parent + ev.stopPropagation(); + var cal = $('#' + $(this).data('colpickId')); + cal.data('colpick').onBeforeShow.apply(this, [cal.get(0)]); + var pos = $(this).offset(); + var top = pos.top + this.offsetHeight; + var left = pos.left; + var viewPort = getViewport(); + var calW = cal.width(); + if (left + calW > viewPort.l + viewPort.w) { + left -= calW; + } + cal.css({left: left + 'px', top: top + 'px'}); + if (cal.data('colpick').onShow.apply(this, [cal.get(0)]) != false) { + cal.show(); + } + //Hide when user clicks outside + $('html').mousedown({cal:cal}, hide); + cal.mousedown(function(ev){ev.stopPropagation();}) + }, + hide = function (ev) { + if (ev.data.cal.data('colpick').onHide.apply(this, [ev.data.cal.get(0)]) != false) { + ev.data.cal.hide(); + } + $('html').off('mousedown', hide); + }, + getViewport = function () { + var m = document.compatMode == 'CSS1Compat'; + return { + l : window.pageXOffset || (m ? document.documentElement.scrollLeft : document.body.scrollLeft), + w : window.innerWidth || (m ? document.documentElement.clientWidth : document.body.clientWidth) + }; + }, + //Fix the values if the user enters a negative or high value + fixHSB = function (hsb) { + return { + h: Math.min(360, Math.max(0, hsb.h)), + s: Math.min(100, Math.max(0, hsb.s)), + b: Math.min(100, Math.max(0, hsb.b)) + }; + }, + fixRGB = function (rgb) { + return { + r: Math.min(255, Math.max(0, rgb.r)), + g: Math.min(255, Math.max(0, rgb.g)), + b: Math.min(255, Math.max(0, rgb.b)) + }; + }, + fixHex = function (hex) { + var len = 6 - hex.length; + if (len > 0) { + var o = []; + for (var i=0; i
').attr('style','height:8.333333%; filter:progid:DXImageTransform.Microsoft.gradient(GradientType=0,startColorstr='+stops[i]+', endColorstr='+stops[i+1]+'); -ms-filter: "progid:DXImageTransform.Microsoft.gradient(GradientType=0,startColorstr='+stops[i]+', endColorstr='+stops[i+1]+')";'); + huebar.append(div); + } + } else { + var stopList = stops.join(','); + huebar.attr('style','background:-webkit-linear-gradient(top,'+stopList+'); background: -o-linear-gradient(top,'+stopList+'); background: -ms-linear-gradient(top,'+stopList+'); background:-moz-linear-gradient(top,'+stopList+'); -webkit-linear-gradient(top,'+stopList+'); background:linear-gradient(to bottom,'+stopList+'); '); + } + cal.find('div.colpick_hue').on('mousedown touchstart',downHue); + options.newColor = cal.find('div.colpick_new_color'); + options.currentColor = cal.find('div.colpick_current_color'); + //Store options and fill with default color + cal.data('colpick', options); + fillRGBFields(options.color, cal.get(0)); + fillHSBFields(options.color, cal.get(0)); + fillHexFields(options.color, cal.get(0)); + setHue(options.color, cal.get(0)); + setSelector(options.color, cal.get(0)); + setCurrentColor(options.color, cal.get(0)); + setNewColor(options.color, cal.get(0)); + //Append to body if flat=false, else show in place + if (options.flat) { + cal.appendTo(this).show(); + cal.css({ + position: 'relative', + display: 'block' + }); + } else { + cal.appendTo(document.body); + $(this).on(options.showEvent, show); + cal.css({ + position:'absolute' + }); + } + } + }); + }, + //Shows the picker + showPicker: function() { + return this.each( function () { + if ($(this).data('colpickId')) { + show.apply(this); + } + }); + }, + //Hides the picker + hidePicker: function() { + return this.each( function () { + if ($(this).data('colpickId')) { + $('#' + $(this).data('colpickId')).hide(); + } + }); + }, + //Sets a color as new and current (default) + setColor: function(col, setCurrent) { + setCurrent = (typeof setCurrent === "undefined") ? 1 : setCurrent; + if (typeof col == 'string') { + col = hexToHsb(col); + } else if (col.r != undefined && col.g != undefined && col.b != undefined) { + col = rgbToHsb(col); + } else if (col.h != undefined && col.s != undefined && col.b != undefined) { + col = fixHSB(col); + } else { + return this; + } + return this.each(function(){ + if ($(this).data('colpickId')) { + var cal = $('#' + $(this).data('colpickId')); + cal.data('colpick').color = col; + cal.data('colpick').origColor = col; + fillRGBFields(col, cal.get(0)); + fillHSBFields(col, cal.get(0)); + fillHexFields(col, cal.get(0)); + setHue(col, cal.get(0)); + setSelector(col, cal.get(0)); + + setNewColor(col, cal.get(0)); + cal.data('colpick').onChange.apply(cal.parent(), [col, hsbToHex(col), hsbToRgb(col), cal.data('colpick').el, 1]); + if(setCurrent) { + setCurrentColor(col, cal.get(0)); + } + } + }); + } + }; +}(); +//Color space convertions +var hexToRgb = function (hex) { + var hex = parseInt(((hex.indexOf('#') > -1) ? hex.substring(1) : hex), 16); + return {r: hex >> 16, g: (hex & 0x00FF00) >> 8, b: (hex & 0x0000FF)}; +}; +var hexToHsb = function (hex) { + return rgbToHsb(hexToRgb(hex)); +}; +var rgbToHsb = function (rgb) { + var hsb = {h: 0, s: 0, b: 0}; + var min = Math.min(rgb.r, rgb.g, rgb.b); + var max = Math.max(rgb.r, rgb.g, rgb.b); + var delta = max - min; + hsb.b = max; + hsb.s = max != 0 ? 255 * delta / max : 0; + if (hsb.s != 0) { + if (rgb.r == max) hsb.h = (rgb.g - rgb.b) / delta; + else if (rgb.g == max) hsb.h = 2 + (rgb.b - rgb.r) / delta; + else hsb.h = 4 + (rgb.r - rgb.g) / delta; + } else hsb.h = -1; + hsb.h *= 60; + if (hsb.h < 0) hsb.h += 360; + hsb.s *= 100/255; + hsb.b *= 100/255; + return hsb; +}; +var hsbToRgb = function (hsb) { + var rgb = {}; + var h = hsb.h; + var s = hsb.s*255/100; + var v = hsb.b*255/100; + if(s == 0) { + rgb.r = rgb.g = rgb.b = v; + } else { + var t1 = v; + var t2 = (255-s)*v/255; + var t3 = (t1-t2)*(h%60)/60; + if(h==360) h = 0; + if(h<60) {rgb.r=t1; rgb.b=t2; rgb.g=t2+t3} + else if(h<120) {rgb.g=t1; rgb.b=t2; rgb.r=t1-t3} + else if(h<180) {rgb.g=t1; rgb.r=t2; rgb.b=t2+t3} + else if(h<240) {rgb.b=t1; rgb.r=t2; rgb.g=t1-t3} + else if(h<300) {rgb.b=t1; rgb.g=t2; rgb.r=t2+t3} + else if(h<360) {rgb.r=t1; rgb.g=t2; rgb.b=t1-t3} + else {rgb.r=0; rgb.g=0; rgb.b=0} + } + return {r:Math.round(rgb.r), g:Math.round(rgb.g), b:Math.round(rgb.b)}; +}; +var rgbToHex = function (rgb) { + var hex = [ + rgb.r.toString(16), + rgb.g.toString(16), + rgb.b.toString(16) + ]; + $.each(hex, function (nr, val) { + if (val.length == 1) { + hex[nr] = '0' + val; + } + }); + return hex.join(''); +}; +var hsbToHex = function (hsb) { + return rgbToHex(hsbToRgb(hsb)); +}; +$.fn.extend({ + colpick: colpick.init, + colpickHide: colpick.hidePicker, + colpickShow: colpick.showPicker, + colpickSetColor: colpick.setColor +}); +$.extend({ + colpick:{ + rgbToHex: rgbToHex, + rgbToHsb: rgbToHsb, + hsbToHex: hsbToHex, + hsbToRgb: hsbToRgb, + hexToHsb: hexToHsb, + hexToRgb: hexToRgb + } +}); diff --git a/app/javascript/legacy/common/vendor/jquery.cookie.js b/app/javascript/legacy/common/vendor/jquery.cookie.js new file mode 100755 index 00000000..f20f9e38 --- /dev/null +++ b/app/javascript/legacy/common/vendor/jquery.cookie.js @@ -0,0 +1,110 @@ +// License: LGPL-3.0-or-later +/*! + * jQuery Cookie Plugin v1.4.1 + * https://github.com/carhartl/jquery-cookie + * + * Copyright 2013 Klaus Hartl + * Released under the MIT license + */ +(function (factory) { + factory(jQuery); +}(function ($) { + + var pluses = /\+/g; + + function encode(s) { + return config.raw ? s : encodeURIComponent(s); + } + + function decode(s) { + return config.raw ? s : decodeURIComponent(s); + } + + function stringifyCookieValue(value) { + return encode(config.json ? JSON.stringify(value) : String(value)); + } + + function parseCookieValue(s) { + if (s.indexOf('"') === 0) { + // This is a quoted cookie as according to RFC2068, unescape... + s = s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + } + + try { + // Replace server-side written pluses with spaces. + // If we can't decode the cookie, ignore it, it's unusable. + // If we can't parse the cookie, ignore it, it's unusable. + s = decodeURIComponent(s.replace(pluses, ' ')); + return config.json ? JSON.parse(s) : s; + } catch(e) {} + } + + function read(s, converter) { + var value = config.raw ? s : parseCookieValue(s); + return $.isFunction(converter) ? converter(value) : value; + } + + var config = $.cookie = function (key, value, options) { + + // Write + + if (value !== undefined && !$.isFunction(value)) { + options = $.extend({}, config.defaults, options); + + if (typeof options.expires === 'number') { + var days = options.expires, t = options.expires = new Date(); + t.setTime(+t + days * 864e+5); + } + + return (document.cookie = [ + encode(key), '=', stringifyCookieValue(value), + options.expires ? '; expires=' + options.expires.toUTCString() : '', // use expires attribute, max-age is not supported by IE + options.path ? '; path=' + options.path : '', + options.domain ? '; domain=' + options.domain : '', + options.secure ? '; secure' : '' + ].join('')); + } + + // Read + + var result = key ? undefined : {}; + + // To prevent the for loop in the first place assign an empty array + // in case there are no cookies at all. Also prevents odd result when + // calling $.cookie(). + var cookies = document.cookie ? document.cookie.split('; ') : []; + + for (var i = 0, l = cookies.length; i < l; i++) { + var parts = cookies[i].split('='); + var name = decode(parts.shift()); + var cookie = parts.join('='); + + if (key && key === name) { + // If second argument (value) is a function it's a converter... + result = read(cookie, value); + break; + } + + // Prevent storing a cookie that we couldn't decode. + if (!key && (cookie = read(cookie)) !== undefined) { + result[name] = cookie; + } + } + + return result; + }; + + config.defaults = {}; + + + $.removeCookie = function (key, options) { + if ($.cookie(key) === undefined) { + return false; + } + + // Must not alter options, thus extending a fresh object... + $.cookie(key, '', $.extend({}, options, { expires: -1 })); + return !$.cookie(key); + }; + +})); diff --git a/app/javascript/legacy/common/vendor/masonry.js b/app/javascript/legacy/common/vendor/masonry.js new file mode 100644 index 00000000..1d312791 --- /dev/null +++ b/app/javascript/legacy/common/vendor/masonry.js @@ -0,0 +1,9 @@ +// License: LGPL-3.0-or-later +/*! + * Salvattore 1.0.8 by @rnmp and @ppold + * https://github.com/rnmp/salvattore + */ +!function(e,t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?module.exports=t():e.salvattore=t()}(this,function(){/*! matchMedia() polyfill - Test a CSS media type/query in JS. Authors & copyright (c) 2012: Scott Jehl, Paul Irish, Nicholas Zakas, David Knight. Dual MIT/BSD license */ +window.matchMedia||(window.matchMedia=function(){"use strict";var e=window.styleMedia||window.media;if(!e){var t=document.createElement("style"),n=document.getElementsByTagName("script")[0],r=null;t.type="text/css",t.id="matchmediajs-test",n.parentNode.insertBefore(t,n),r="getComputedStyle"in window&&window.getComputedStyle(t,null)||t.currentStyle,e={matchMedium:function(e){var n="@media "+e+"{ #matchmediajs-test { width: 1px; } }";return t.styleSheet?t.styleSheet.cssText=n:t.textContent=n,"1px"===r.width}}}return function(t){return{matches:e.matchMedium(t||"all"),media:t||"all"}}}()),/*! matchMedia() polyfill addListener/removeListener extension. Author & copyright (c) 2012: Scott Jehl. Dual MIT/BSD license */ +function(){"use strict";if(window.matchMedia&&window.matchMedia("all").addListener)return!1;var e=window.matchMedia,t=e("only all").matches,n=!1,r=0,a=[],i=function(t){clearTimeout(r),r=setTimeout(function(){for(var t=0,n=a.length;n>t;t++){var r=a[t].mql,i=a[t].listeners||[],o=e(r.media).matches;if(o!==r.matches){r.matches=o;for(var c=0,l=i.length;l>c;c++)i[c].call(window,r)}}},30)};window.matchMedia=function(r){var o=e(r),c=[],l=0;return o.addListener=function(e){t&&(n||(n=!0,window.addEventListener("resize",i,!0)),0===l&&(l=a.push({mql:o,listeners:c})),c.push(e))},o.removeListener=function(e){for(var t=0,n=c.length;n>t;t++)c[t]===e&&c.splice(t,1)},o}}(),function(){"use strict";for(var e=0,t=["ms","moz","webkit","o"],n=0;n *:nth-child("+o+"n-"+d+")",s.push(n.querySelectorAll(a));s.forEach(function(e){var n=t.createElement("div"),r=t.createDocumentFragment();n.className=l.join(" "),Array.prototype.forEach.call(e,function(e){r.appendChild(e)}),n.appendChild(r),u.appendChild(n)}),e.appendChild(u),c(e,"columns",o)},r.removeColumns=function(n){var r=t.createRange();r.selectNodeContents(n);var a=Array.prototype.filter.call(r.extractContents().childNodes,function(t){return t instanceof e.HTMLElement}),i=a.length,o=a[0].childNodes.length,l=new Array(o*i);Array.prototype.forEach.call(a,function(e,t){Array.prototype.forEach.call(e.children,function(e,n){l[n*i+t]=e})});var s=t.createElement("div");return c(s,"columns",0),l.filter(function(e){return!!e}).forEach(function(e){s.appendChild(e)}),s},r.recreateColumns=function(t){e.requestAnimationFrame(function(){r.addColumns(t,r.removeColumns(t));var e=new CustomEvent("columnsChange");t.dispatchEvent(e)})},r.mediaQueryChange=function(e){e.matches&&Array.prototype.forEach.call(a,r.recreateColumns)},r.getCSSRules=function(e){var t;try{t=e.sheet.cssRules||e.sheet.rules}catch(n){return[]}return t||[]},r.getStylesheets=function(){var e=Array.prototype.slice.call(t.querySelectorAll("style"));return e.forEach(function(t,n){"text/css"!==t.type&&""!==t.type&&e.splice(n,1)}),Array.prototype.concat.call(e,Array.prototype.slice.call(t.querySelectorAll("link[rel='stylesheet']")))},r.mediaRuleHasColumnsSelector=function(e){var t,n;try{t=e.length}catch(r){t=0}for(;t--;)if(n=e[t],n.selectorText&&n.selectorText.match(/\[data-columns\](.*)::?before$/))return!0;return!1},r.scanMediaQueries=function(){var t=[];if(e.matchMedia){r.getStylesheets().forEach(function(e){Array.prototype.forEach.call(r.getCSSRules(e),function(e){try{e.media&&e.cssRules&&r.mediaRuleHasColumnsSelector(e.cssRules)&&t.push(e)}catch(n){}})});var n=i.filter(function(e){return-1===t.indexOf(e)});o.filter(function(e){return-1!==n.indexOf(e.rule)}).forEach(function(e){e.mql.removeListener(r.mediaQueryChange)}),o=o.filter(function(e){return-1===n.indexOf(e.rule)}),t.filter(function(e){return-1==i.indexOf(e)}).forEach(function(t){var n=e.matchMedia(t.media.mediaText);n.addListener(r.mediaQueryChange),o.push({rule:t,mql:n})}),i.length=0,i=t}},r.rescanMediaQueries=function(){r.scanMediaQueries(),Array.prototype.forEach.call(a,r.recreateColumns)},r.nextElementColumnIndex=function(e,t){var n,r,a,i=e.children,o=i.length,c=0,l=0;for(a=0;o>a;a++)n=i[a],r=n.children.length+(t[a].children||t[a].childNodes).length,0===c&&(c=r),c>r&&(l=a,c=r);return l},r.createFragmentsList=function(e){for(var n=new Array(e),r=0;r!==e;)n[r]=t.createDocumentFragment(),r++;return n},r.appendElements=function(e,t){var n=e.children,a=n.length,i=r.createFragmentsList(a);Array.prototype.forEach.call(t,function(t){var n=r.nextElementColumnIndex(e,i);i[n].appendChild(t)}),Array.prototype.forEach.call(n,function(e,t){e.appendChild(i[t])})},r.prependElements=function(e,n){var a=e.children,i=a.length,o=r.createFragmentsList(i),c=i-1;n.forEach(function(e){var t=o[c];t.insertBefore(e,t.firstChild),0===c?c=i-1:c--}),Array.prototype.forEach.call(a,function(e,t){e.insertBefore(o[t],e.firstChild)});for(var l=t.createDocumentFragment(),s=n.length%i;0!==s--;)l.appendChild(e.lastChild);e.insertBefore(l,e.firstChild)},r.registerGrid=function(n){if("none"!==e.getComputedStyle(n).display){var i=t.createRange();i.selectNodeContents(n);var o=t.createElement("div");o.appendChild(i.extractContents()),c(o,"columns",0),r.addColumns(n,o),a.push(n)}},r.init=function(){var e=t.createElement("style");e.innerHTML="[data-columns]::before{display:block;visibility:hidden;position:absolute;font-size:1px;}",t.head.appendChild(e);var n=t.querySelectorAll("[data-columns]");Array.prototype.forEach.call(n,r.registerGrid),r.scanMediaQueries()},r.init(),{appendElements:r.appendElements,prependElements:r.prependElements,registerGrid:r.registerGrid,recreateColumns:r.recreateColumns,rescanMediaQueries:r.rescanMediaQueries,init:r.init,append_elements:r.appendElements,prepend_elements:r.prependElements,register_grid:r.registerGrid,recreate_columns:r.recreateColumns,rescan_media_queries:r.rescanMediaQueries}}(window,window.document);return e}); + diff --git a/app/javascript/legacy/components/activity_feed.js b/app/javascript/legacy/components/activity_feed.js new file mode 100644 index 00000000..10733a7e --- /dev/null +++ b/app/javascript/legacy/components/activity_feed.js @@ -0,0 +1,3 @@ +// License: LGPL-3.0-or-later + + diff --git a/app/javascript/legacy/components/address-autocomplete-fields.js b/app/javascript/legacy/components/address-autocomplete-fields.js new file mode 100644 index 00000000..596783fd --- /dev/null +++ b/app/javascript/legacy/components/address-autocomplete-fields.js @@ -0,0 +1,96 @@ +// License: LGPL-3.0-or-later +const flyd = require('flyd') +const h = require('snabbdom/h') +const R = require('ramda') +flyd.lift = require('flyd/module/lift') + + + +function init(state, params$) { + state = state||{} + state = R.merge({ + isManual$: flyd.stream(false) + , data$: flyd.stream(app.profile ? R.pick(['address', 'city', 'state_code', 'zip_code'], app.profile) : {}) + , autocompleteInputInserted$: flyd.stream() + }, state) + + state.isManual$ = flyd.stream(true) + state.params$ = params$ + return state +} + +function calculateToShip(state) +{ + return state.params$().gift_option && state.params$().gift_option.to_ship +} + +function view(state) { + return h('section.u-padding--5.pastelBox--grey clearfix', [ + calculateToShip(state) + ? h('label.u-centered.u-marginBottom--5', [ + 'Shipping address (required)' + ]) + : '' + , manualFields(state) + ]) +} + +const manualFields = state => { + return h('div', [ + h('fieldset.col-8.u-fontSize--14', [ + h('input.u-marginBottom--0', {props: { + type: 'text' + , title: 'Street Addresss' + , name: 'address' + , placeholder: 'Street Address' + , value: state.data$().address + , required: calculateToShip(state) ? state.params$().gift_option.to_ship : undefined + }}) + ]) + , h('fieldset.col-right-4.u-fontSize--15', [ + h('input.u-marginBottom--0', {props: { + type: 'text' + , name: 'city' + , title: 'City' + , placeholder: 'City' + , value: state.data$().city + , required: calculateToShip(state) ? state.params$().gift_option.to_ship : undefined + }}) + ]) + , h('fieldset.u-marginBottom--0.u-floatL.col-4', [ + h('input.u-marginBottom--0', {props: { + type: 'text' + , name: 'state_code' + , title: 'State/Region' + , placeholder: 'State/Region' + , value: state.data$().state_code + , required: calculateToShip(state) ? state.params$().gift_option.to_ship : undefined + }}) + ]) + , h('fieldset.u-marginBottom--0.u-floatL.col-right-4.u-fontSize--14', [ + h('input.u-marginBottom--0', {props: { + type: 'text' + , title: 'Zip/Postal' + , name: 'zip_code' + , placeholder: 'Zip/Postal' + , value: state.data$().zip_code + , required: calculateToShip(state) ? state.params$().gift_option.to_ship : undefined + }}) + ]) + , h('fieldset.u-marginBottom--0.u-floatL.col-right-4', [ + h('input.u-marginBottom--0', {props: { + type: 'text' + , title: 'Country' + , name: 'country' + , placeholder: 'Country' + , value: state.data$().country + , required: calculateToShip(state) ? state.params$().gift_option.to_ship : undefined + }}) + ]) + ]) +} + + +module.exports = {init, view} + + diff --git a/app/javascript/legacy/components/address-autocomplete.js b/app/javascript/legacy/components/address-autocomplete.js new file mode 100644 index 00000000..dd756057 --- /dev/null +++ b/app/javascript/legacy/components/address-autocomplete.js @@ -0,0 +1,81 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const flyd = require('flyd') + +// Stream that has true when google script is loaded +const loaded$ = flyd.stream() +// Stream of autocomplete data +const data$ = flyd.stream() + +function initScript() { + // if(document.getElementById('googleAutocomplete')) return + // var script = document.createElement('script') + // script.type = 'text/javascript' + // script.id = 'googleAutocomplete' + // document.body.appendChild(script) + // script.src = `https://maps.googleapis.com/maps/api/js?key=${app.google_api}&libraries=places&callback=initGoogleAutocomplete` + return loaded$ +} + +window.initGoogleAutocomplete = () => loaded$(true) + +function initInput(input) { + var autocomplete = new google.maps.places.Autocomplete(input, {types: ['geocode']}) + autocomplete.addListener('place_changed', fillInAddress(autocomplete)) + input.addEventListener('focus', geolocate(autocomplete)) + input.addEventListener('keydown', e => { if(e.which === 13) e.preventDefault() }) + return data$ +} + +const acceptedTypes = { + street_number: 'short_name' +, route: 'long_name' +, locality: 'long_name' +, administrative_area_level_1: 'short_name' +, country: 'long_name' +, postal_code: 'short_name' +} + +const fillInAddress = autocomplete => () => { + var place = { components: autocomplete.getPlace().address_components} + if(!place.components) return + place.types = R.map(x => x.types[0], place.components) + var address = placeData(place, 'street_number') + ? placeData(place, 'street_number') + ' ' + placeData(place, 'route') + : '' + + var data = { + address: address + , city: placeData(place, 'locality') + , state_code: placeData(place, 'administrative_area_level_1') + , country: placeData(place, 'country') + , zip_code: placeData(place, 'postal_code') + } + data$(data) +} + +function placeData(place, key) { + const i = R.findIndex(R.equals(key), place.types) + if(i >= 0) return place.components[i][acceptedTypes[key]] + return '' +} + +// Bias the autocomplete object to the user's geographical location, +// as supplied by the browser's 'navigator.geolocation' object. +const geolocate = autocomplete => () => { + if(!navigator || !navigator.geolocation) return + navigator.geolocation.getCurrentPosition(pos => { + var geolocation = { + lat: pos.coords.latitude + , lng: pos.coords.longitude + } + var circle = new google.maps.Circle({ + center: geolocation + , radius: pos.coords.accuracy + }) + autocomplete.setBounds(circle.getBounds()) + }) +} + + +module.exports = {initScript, initInput, data$, loaded$} diff --git a/app/javascript/legacy/components/ajax/toggle_soft_delete.js b/app/javascript/legacy/components/ajax/toggle_soft_delete.js new file mode 100644 index 00000000..ab644013 --- /dev/null +++ b/app/javascript/legacy/components/ajax/toggle_soft_delete.js @@ -0,0 +1,15 @@ +// License: LGPL-3.0-or-later +var request = require('../../common/client') + +module.exports = function(url, type) { + appl.def('toggle_soft_delete', function(bool) { + appl.def('loading', true) + var action = bool ? 'deleted.' : 'undeleted.' + request.put(url + '/soft_delete', {delete: bool}).end(function(err, resp) { + appl.def('loading', false) + .def(type + '_is_deleted', bool) + .notify('Successfully ' + action) + .close_modal() + }) + }) +} diff --git a/app/javascript/legacy/components/b64.js b/app/javascript/legacy/components/b64.js new file mode 100644 index 00000000..d7ec4219 --- /dev/null +++ b/app/javascript/legacy/components/b64.js @@ -0,0 +1,13 @@ +// License: LGPL-3.0-or-later +// see https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding +// used for encoded and decoding data for email text + +module.exports = { + encode: str => + btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g + , (match, p1) => String.fromCharCode('0x' + p1))).replace(/\//g,'_').replace(/\+/g,'-') + , decode: str => + decodeURIComponent(Array.prototype.map.call(atob(str.replace(/-/g, '+').replace(/_/g, '/')) + , c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')) +} + diff --git a/app/javascript/legacy/components/branded_fundraising.js b/app/javascript/legacy/components/branded_fundraising.js new file mode 100644 index 00000000..efe857c8 --- /dev/null +++ b/app/javascript/legacy/components/branded_fundraising.js @@ -0,0 +1,10 @@ +// License: LGPL-3.0-or-later +const brandColors = require('../components/nonprofit-branding') + +$('[if-branded]').each(function() { + var params = this.getAttribute("if-branded").split(',').map(function(s) { return s.trim() }) + $(this).css(params[0], brandColors[params[1]]) +}) + +exports = brandColors + diff --git a/app/javascript/legacy/components/card-form.es6 b/app/javascript/legacy/components/card-form.es6 new file mode 100644 index 00000000..83a40a19 --- /dev/null +++ b/app/javascript/legacy/components/card-form.es6 @@ -0,0 +1,190 @@ +// License: LGPL-3.0-or-later +// npm +const h = require('snabbdom/h') +const R = require('ramda') +const validatedForm = require('ff-core/validated-form') +const button = require('ff-core/button') +const flyd = require('flyd') +flyd.flatMap = require('flyd/module/flatmap') +flyd.filter = require('flyd/module/filter') +flyd.mergeAll = require('flyd/module/mergeall') +const scanMerge = require('flyd/module/scanmerge') +// local +const request = require('../common/request') +const formatErr = require('../common/format_response_error') +const createCardStream = require('../cards/create-frp.es6') +const serializeForm = require('form-serialize') +const luhnCheck = require('../common/credit-card-validator.js') + +// A component for filling out card data, validating it, saving the card to +// stripe, and then saving a tokenized copy to our servers. + +// Form validation constraints, validator functions, and error messages: +var constraints = { + address_zip: {required: true} +, name: {required: true} +, number: {required: true, cardNumber: true} +, exp_month: {required: true, format: /\d\d?/} +, exp_year: {required: true, format: /\d\d?/} +, cvc: {required: true, format: /\d\d\d\d?/} +} +var validators = { cardNumber: luhnCheck } +var messages = { + number: { + required: I18n.t('nonprofits.donate.payment.card.errors.number.presence') + , cardNumber: I18n.t('nonprofits.donate.payment.card.errors.number.format') + } + , required: I18n.t('nonprofits.donate.payment.card.errors.field.presence') + , email: I18n.t('nonprofits.donate.payment.card.errors.email.format') + , format: I18n.t('nonprofits.donate.payment.card.errors.field.format') +} + +// You can pass in the .hideButton boolean if you want to control whether the submit button is shown/hidden +// Pass in a .card object, which can have default objects for the card form (name, number, cvc, exp_month, etc) +// Pass in .path to set the endpoint for saving the card +// Pass in .payload for default data to send to the server for every card save request (such as a request token) +const init = (state) => { + state = state || {} + // set defaults + state = R.merge({ + payload$: flyd.stream(state.payload || {}) + , path$: flyd.stream(state.path || '/cards') + }, state) + + state.form = validatedForm.init({constraints, validators, messages}) + state.card$ = flyd.merge(flyd.stream(state.card || {}), state.form.validData$) + + // streams of stripe tokenization responses + const stripeResp$ = flyd.flatMap(createCardStream, state.form.validData$) + state.stripeRespOk$ = flyd.filter(r => !r.error, stripeResp$) + const stripeError$ = flyd.map(r => r.error.message, flyd.filter(r => r.error, stripeResp$)) + + // Save the card as a card table on our own db + // streams of responses + state.resp$ = flyd.flatMap( + resp => saveCard(state.payload$(), state.path$(), resp) // cheating on the streams here.. + , state.stripeRespOk$ ) + + const ccError$ = flyd.map(R.prop('error'), flyd.filter(resp => resp.error, state.resp$)) + state.saved$ = flyd.filter(resp => !resp.error, state.resp$) + state.error$ = flyd.merge(stripeError$, ccError$) + + state.loading$ = scanMerge([ + [state.form.validSubmit$, R.always(true)] + , [state.error$, R.always(false)] + , [state.saved$, R.always(false)] + ], false) + + return state +} + + +// -- Stream-related functions + +// Save the card to our own servers, and return a response stream +const saveCard = (send, path, resp) => { + send.card = R.merge(send.card, { + cardholders_name: resp.name + , name: `${resp.card.brand} *${resp.card.last4}` + , stripe_card_token: resp.id + , stripe_card_id: resp.card.id + }) + return flyd.map(R.prop('body'), request({ path, send, method: 'post' }).load) +} + + +// -- Virtual DOM + +const view = state => { + var field = validatedForm.field(state.form) + return validatedForm.form(state.form, h('form.cardForm', [ + h('div.u-background--grey.group.u-padding--8', [ + nameInput(field, state.card$().name) + , numberInput(field) + , cvcInput(field) + , expMonthInput(field) + , expYearInput(field) + , zipInput(field, state.card$().address_zip) + , profileInput(field, app.profile_id) // XXX global + ]) + , h('div.u-centered.u-marginTop--20', [ + state.hideButton ? '' : button({ + error$: state.hideErrors ? flyd.stream() : state.error$ + , loading$: state.loading$ + , buttonText: I18n.t('nonprofits.donate.payment.card.submit') + , loadingText: ` ${I18n.t('nonprofits.donate.payment.card.loading')}` + }) + , h('p.u-fontSize--12.u-marginBottom--0.u-marginTop--10.u-color--grey', [ h('i.fa.fa-lock'), ` ${I18n.t('nonprofits.donate.payment.card.secure_info')}`]) + ]) + ]) ) +} + + +const nameInput = (field, name) => + h('fieldset', [ field(h('input', { props: { name: 'name' , value: name || '', placeholder: I18n.t('nonprofits.donate.payment.card.name') } })) ]) + + +const numberInput = field => + h('fieldset.col-8', [ field(h('input', {props: { type: 'text' , name: 'number' , placeholder: I18n.t('nonprofits.donate.payment.card.number') } })) ]) + + +const cvcInput = field => + h('fieldset.col-right-4.u-relative', [ + field(h('input', { props: { name: 'cvc' , placeholder: I18n.t('nonprofits.donate.payment.card.cvc') } } )) + , h('img.security-code-image', { + src: `${app.asset_path}/graphics/cc-security-code.png` + }) + ]) + + +const expMonthInput = field => { + var options = R.prepend( + h('option.default', {props: {value: undefined, selected: true}}, I18n.t('nonprofits.donate.payment.card.month')) + , R.range(1, 13).map(n => h('option', String(n))) + ) + return h('fieldset.col-3.u-margin--0', [ + field(h('select.select' + , { props: {name: 'exp_month'} } + , options)) + ]) +} + + +const expYearInput = field => { + var yearRange = R.range(new Date().getFullYear(), new Date().getFullYear() + 15) + var options = R.prepend( + h('option.default', {props: {value: undefined, selected: true}}, I18n.t('nonprofits.donate.payment.card.year')) + , R.map(y => h('option', String(y)), yearRange) + ) + return h('fieldset.col-left-3.u-margin--0', [ + field(h('select.select' + , {props: {name: 'exp_year'}} + , options)) + ]) +} + + +const zipInput = (field, zip) => + h('fieldset.col-right-6.u-margin--0', [ + field(h('input' + , { props: { + type: 'text' + , name: 'address_zip' + , value: zip || '' + , placeholder: I18n.t('nonprofits.donate.payment.card.postal_code') + }} + )) + ]) + + +const profileInput = (field, profile_id) => + field(h('input' + , { props: { + type: 'hidden' + , name: 'profile_id' + , value: profile_id || '' + }} + )) + +module.exports = {view, init} + diff --git a/app/javascript/legacy/components/chart-options.js b/app/javascript/legacy/components/chart-options.js new file mode 100644 index 00000000..77c0b1c3 --- /dev/null +++ b/app/javascript/legacy/components/chart-options.js @@ -0,0 +1,29 @@ +// License: LGPL-3.0-or-later +var chartOptions = {} + +chartOptions.default = { + defaultFontFamily: "'Open Sans', 'Helvetica Neue', 'Arial', 'sans-serif'" +, scales: { + yAxes: [{ ticks: { min: 0 }}] + } +} + +chartOptions.dollars = { + defaultFontFamily: "'Open Sans', 'Helvetica Neue', 'Arial', 'sans-serif'" +, scales: { + yAxes: [{ ticks: { + min: 0 + , callback: (val) => '$' + utils.cents_to_dollars(val) + } }] + } +, tooltips: { + callbacks: { + label: (item, data) => + data.datasets[item.datasetIndex].label + + ': $' + utils.cents_to_dollars(item.yLabel) + } + } +} + +module.exports = chartOptions + diff --git a/app/javascript/legacy/components/checkbox.js b/app/javascript/legacy/components/checkbox.js new file mode 100644 index 00000000..42ff14b4 --- /dev/null +++ b/app/javascript/legacy/components/checkbox.js @@ -0,0 +1,15 @@ +// License: LGPL-3.0-or-later +const h = require('flimflam/h') +const uuid = require('uuid') + +// example: +// checkbox({name: 'anonymous', value: 'true', label: 'Donate anonymously?'}) + +module.exports = obj => { + const id = uuid.v1() + return h('div', [ + h('input', {props: {type: 'checkbox', id, value: obj.value, name: obj.name}}) + , h('label', {attrs: {for: id}}, [h('span.pl-1.sub.font-weight-1', obj.label ? obj.label : obj.value)]) + ]) +} + diff --git a/app/javascript/legacy/components/color-picker.es6 b/app/javascript/legacy/components/color-picker.es6 new file mode 100644 index 00000000..49fa058b --- /dev/null +++ b/app/javascript/legacy/components/color-picker.es6 @@ -0,0 +1,32 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const flyd = require('flyd') +const R = require('ramda') +require('../common/vendor/colpick') // XXX jquery + +// Color picker UI component, wrapping the colpick jquery plugin +// You can use colorPicker.streams.color to access a stream of hex color values selected by the user +// Will also set colorPicker.state.color for every selected color value + +function init(defaultColor) { + var logoBlue = '#42B3DF' + return {color$: flyd.stream(defaultColor || logoBlue)} +} + +const view = state => + h('div.colPick-wrapper.inner#colorpicker', { + hook: { + insert: (vnode) => { + $(vnode.elm).colpick({ + flat: true + , layout: 'hex' + , submit: false + , color: state.color$() + , onChange: (hsb, hex, rgb, el, bySetColor) => state.color$('#' + hex) + }) + } + } + }) + +module.exports = {init, view} + diff --git a/app/javascript/legacy/components/confirmation-modal.js b/app/javascript/legacy/components/confirmation-modal.js new file mode 100644 index 00000000..cd867a2c --- /dev/null +++ b/app/javascript/legacy/components/confirmation-modal.js @@ -0,0 +1,44 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const h = require('snabbdom/h') +const uuid = require('uuid') +const flyd = require('flyd') +const modal = require('ff-core/modal') +const mergeAll = require('flyd/module/mergeall') + +// show$ is the stream that shows the confirmation modal +const init = show$ => { + const state = { + confirm$: flyd.stream() + , unconfirm$: flyd.stream() + , ID: uuid.v1() + } + + state.modalID$ = mergeAll([ + flyd.map(R.always(state.ID), show$) + , flyd.map(R.always(null), state.unconfirm$) + , flyd.map(R.always(null), state.confirm$)]) + + return state +} + +// msg is optional +const view = (state, msg) => + modal({ + id$: state.modalID$ + , thisID: state.ID + , notCloseable: true + , body: h('div', [ + h('h4', msg || 'Are you sure?') + , h('div', [ + h('button', {attrs: {'data-ff-confirm': true}, on: {click: state.confirm$}} + , 'Yes') + , h('button', {attrs: {'data-ff-confirm': false}, on: {click: state.unconfirm$}} + , 'No') + ]) + ]) + }) + + +module.exports = {init, view} + diff --git a/app/javascript/legacy/components/date-range.js b/app/javascript/legacy/components/date-range.js new file mode 100644 index 00000000..f7873a9d --- /dev/null +++ b/app/javascript/legacy/components/date-range.js @@ -0,0 +1,11 @@ +// License: LGPL-3.0-or-later +const moment = require('moment') +require('moment-range') + +// returns an array of moments +// timeSpan is one of 'day, 'week', 'month', 'year' (see moment.js docs) +module.exports = (startDate, endDate, timeSpan) => { + var dates = [moment(startDate), moment(endDate)] + return moment.range(dates).toArray(timeSpan) +} + diff --git a/app/javascript/legacy/components/date_range_picker.js b/app/javascript/legacy/components/date_range_picker.js new file mode 100644 index 00000000..e0752fe4 --- /dev/null +++ b/app/javascript/legacy/components/date_range_picker.js @@ -0,0 +1,32 @@ +// License: LGPL-3.0-or-later +var Pikaday = require('pikaday') +var moment = require('moment') + +var el = document.querySelector('#dateRange') +if(el) { + var before_date = el.querySelector('#beforeDate') + var after_date = el.querySelector('#afterDate') +} + +function format_date(el) { + return function(date) { + el.value = moment(date).format('MM/DD/YYYY') + } +} + +if(el && before_date) { + new Pikaday({ + field: before_date, + format: 'MM/DD/YYYY', + onSelect: format_date(before_date) + }) +} + +if(el && after_date) { + new Pikaday({ + field: after_date, + format: 'MM/DD/YYYY', + onSelect: format_date(after_date) + }) +} + diff --git a/app/javascript/legacy/components/dollar-input.js b/app/javascript/legacy/components/dollar-input.js new file mode 100644 index 00000000..86fde2a9 --- /dev/null +++ b/app/javascript/legacy/components/dollar-input.js @@ -0,0 +1,15 @@ +// License: LGPL-3.0-or-later +const h = require('flimflam/h') + +module.exports = (name, placeholder, value) => { +return h('input.dollar-input.max-width-2', { + props: { + type: 'number' + , step: 'any' + , min: 0 + , name + , placeholder + , value + } + }) +} diff --git a/app/javascript/legacy/components/drag-to-reorder.js b/app/javascript/legacy/components/drag-to-reorder.js new file mode 100644 index 00000000..04348eb6 --- /dev/null +++ b/app/javascript/legacy/components/drag-to-reorder.js @@ -0,0 +1,37 @@ +// License: LGPL-3.0-or-later +const dragula = require('dragula') +const serialize = require('form-serialize') +const R = require('ramda') +const request = require('../common/request') +const flyd = require('flyd') +const flatMap = require('flyd/module/flatmap') + +const mapIndex = R.addIndex(R.map) + +module.exports = function(path, containerId, afterUpdateFunction) { + + // Stream of dragged elements + const draggedEls$ = flyd.stream() + + dragula([document.getElementById(containerId)]).on('dragend', draggedEls$) + + // Make a stream of objects with .id and .order + const giftOptions$ = flyd.map( getIdAndOrder , draggedEls$) + + function getIdAndOrder(el) { + var form = el.querySelector('input').form + var ids = serialize(form, {hash: true}).id + return {data: mapIndex((v, i) => ({id: v, order: i}), ids)} + } + + const updateOrdering = send => flyd.map(R.prop('body'), request({path, method: 'put' , send}).load) + + const response$ = flatMap(updateOrdering, giftOptions$) + + // Optional after update function + if(afterUpdateFunction) { + flyd.map(afterUpdateFunction, response$) + } +} + + diff --git a/app/javascript/legacy/components/duplicate_fundraiser.js b/app/javascript/legacy/components/duplicate_fundraiser.js new file mode 100644 index 00000000..5a847d23 --- /dev/null +++ b/app/javascript/legacy/components/duplicate_fundraiser.js @@ -0,0 +1,26 @@ +// License: LGPL-3.0-or-later +const flyd = require('flyd') +const request = require('../common/request') +const flatMap = require('flyd/module/flatmap') +const R = require('ramda') + +function init(prefix, fundraiserId) { + var dupePath = prefix + `/${fundraiserId}/duplicate.json` + var click$ = flyd.stream() + var button = document.getElementById('js-duplicateFundraiser') + + button.addEventListener('click', click$) + + const duplicate = () => { + button.setAttribute('disabled', 'disabled') + button.innerHTML = 'Copying...' + return flyd.map(R.prop('body'), request({path: dupePath, method: 'post'}).load) + } + + const response$ = flatMap(duplicate, click$) + + flyd.map(resp => window.location = prefix + `/${resp.id}`, response$) +} + +module.exports = init + diff --git a/app/javascript/legacy/components/encode-plain-email.js b/app/javascript/legacy/components/encode-plain-email.js new file mode 100644 index 00000000..9a6a782e --- /dev/null +++ b/app/javascript/legacy/components/encode-plain-email.js @@ -0,0 +1,20 @@ +// License: LGPL-3.0-or-later +const b64 = require('./b64') + +module.exports = o => { + var header = [ + 'MIME-Version: 1.0' + , `From: ${o.from}` + , `Reply-To: ${o.from}` + , `To: ${o.to}` + , `Subject: ${o.subject}`] + + if(o.cc) header = header.concat(`Cc: ${o.cc.join(',')}`) + if(o.bcc) header = header.concat(`Bcc: ${o.bcc.join(',')}`) + + var email = header.concat(['Content-Type: text/plain', '', o.body]) + .join('\r\n').trim() + + return b64.encode(email) +} + diff --git a/app/javascript/legacy/components/field-with-error.js b/app/javascript/legacy/components/field-with-error.js new file mode 100644 index 00000000..d730bd10 --- /dev/null +++ b/app/javascript/legacy/components/field-with-error.js @@ -0,0 +1,14 @@ +// License: LGPL-3.0-or-later +const h = require('flimflam/h') +const R = require('ramda') +const validatedForm = require('flimflam/ui/validated-form') + +module.exports = R.curryN(2, (formState, field) => { + const key = R.path(['data','props','name'], field) + const validatedField = validatedForm.field(formState, field) + const err = formState.errors$()[key] + return h('div', { + attrs: {'data-ff-field': err ? 'invalid' : 'valid', 'data-ff-field-error': err || ''} + }, [ validatedField ]) +}) + diff --git a/app/javascript/legacy/components/fundraising/add_header_image.js b/app/javascript/legacy/components/fundraising/add_header_image.js new file mode 100644 index 00000000..68db23e7 --- /dev/null +++ b/app/javascript/legacy/components/fundraising/add_header_image.js @@ -0,0 +1,6 @@ +// License: LGPL-3.0-or-later +if(app.header_image_url) { + var cssString = "display: block; background-image: url(" + app.header_image_url + ")" + document.getElementById('js-fundraisingHeader').className ='fundraisingHeader--image container' + document.getElementById('js-fundraisingHeader-image').style.cssText = cssString +} diff --git a/app/javascript/legacy/components/maps/cc_map.js b/app/javascript/legacy/components/maps/cc_map.js new file mode 100644 index 00000000..2ae7ab66 --- /dev/null +++ b/app/javascript/legacy/components/maps/cc_map.js @@ -0,0 +1,110 @@ +// License: LGPL-3.0-or-later +var request = require('../../common/client') +var map_options = require('./default_options') +var cc_map = {} +var info_window = false +var map_data + +// the endpoint is the only required param +// see maps_controller for endpoint options +cc_map.init = function(endpoint, options_obj, query) { + endpoint = window.location.origin + '/maps/' + endpoint + request.get(endpoint) + .query(query) + .end(function(err, resp) { + map_data = resp.body.data + var has_map = document.getElementById('google_maps') + if (app.map_provider === 'google') { + if (!has_map) { + var script = document.createElement('script') + script.type = 'text/javascript' + script.id = 'google_maps' + let key = "" + if (app.map_provider_options && app.map_provider_options.key) { + key = `key=${app.map_provider_options.key}&` + } + script.src = `https://maps.googleapis.com/maps/api/js?${key}callback=draw_map` + document.body.appendChild(script) + set_extra_options(options_obj) + } else { + set_extra_options(options_obj) + draw_map() + } + } + else { + if (has_map) + { + has_map.innerText = "Sorry, no map provider is installed" + } + else + { + var map = document.getElementById('googleMap') + map.innerText = "Sorry, no map provider is installed" + } + } + }) +} + + +function set_extra_options(obj){ + if(!obj){ + return + } + if(obj.center && obj.center.lat) { + map_options.lat = obj.center.lat + map_options.lng = obj.center.lng + } + map_options.disableDefaultUI = obj.disable_ui ? true : false + map_options.zoom = obj.zoom ? obj.zoom : map_options.zoom + map_options.fit_all = obj.fit_all ? true : false +} + + +window.draw_map = function () { + map_options.center = new google.maps.LatLng(map_options.lat, map_options.lng) + map_options.mapTypeId = google.maps.MapTypeId.NORMAL + var map = new google.maps.Map(document.getElementById('googleMap'), map_options) + add_markers(map) +} + + +function add_markers(map){ + var markers = [] + appl.def('map_data_count', map_data.length) + map_data.forEach(function(data){ + if (!data.latitude) { + return + } + var coordinates = new google.maps.LatLng(data.latitude, data.longitude) + var marker = new google.maps.Marker({ + position: coordinates, + map: map, + draggable: false, + icon: 'https://raw.githubusercontent.com/CommitChange/public-resources/master/images/cc-map-marker-pick-22.png', + data: data + }) + + google.maps.event.addListener(marker, 'click', function() { + if (info_window) { + info_window.close() + } + info_window = new google.maps.InfoWindow({ content: this.data.name }) + info_window.open(map,this) + var map_data = this.data + if(map_data.total_raised) { + map_data.total_raised = utils.cents_to_dollars(map_data.total_raised) + } + appl.def('map_data', map_data) + }) + markers.push(marker) + }) + if(map_options.fit_all) { + var bounds = new google.maps.LatLngBounds(); + for(var i = 0; i < markers.length; i++) { + bounds.extend(markers[i].getPosition()); + } + map.fitBounds(bounds); + } +} + +module.exports = cc_map diff --git a/app/javascript/legacy/components/maps/default_options.js b/app/javascript/legacy/components/maps/default_options.js new file mode 100644 index 00000000..88c7a992 --- /dev/null +++ b/app/javascript/legacy/components/maps/default_options.js @@ -0,0 +1,14 @@ +// License: LGPL-3.0-or-later +var styles = require('./styles'); + +module.exports = { + zoom: 4, + lat: 38.8794, + lng : -97.3222, + styles: styles.discreet, + mapTypeControl: false, + scrollwheel: false, + scaleControl: false, + streetViewControl: false, + overviewMapControl: false +} \ No newline at end of file diff --git a/app/javascript/legacy/components/maps/npo_coordinates.js b/app/javascript/legacy/components/maps/npo_coordinates.js new file mode 100644 index 00000000..f5ffe605 --- /dev/null +++ b/app/javascript/legacy/components/maps/npo_coordinates.js @@ -0,0 +1,9 @@ +// License: LGPL-3.0-or-later +module.exports = function(){ + if(app.nonprofit.latitude) { + return { + lat: app.nonprofit.latitude, + lng: app.nonprofit.longitude, + } + } +} diff --git a/app/javascript/legacy/components/maps/styles.js b/app/javascript/legacy/components/maps/styles.js new file mode 100644 index 00000000..8fa18881 --- /dev/null +++ b/app/javascript/legacy/components/maps/styles.js @@ -0,0 +1,269 @@ +// License: LGPL-3.0-or-later +var Styles = {} + +// style credit: https://snazzymaps.com/style/1735/discreet +// Originally under CC0 1.0 + +Styles.discreet = [ + { + "featureType": "administrative", + "elementType": "all", + "stylers": [ + { + "visibility": "off" + } + ] + }, + { + "featureType": "administrative", + "elementType": "geometry.stroke", + "stylers": [ + { + "visibility": "on" + } + ] + }, + { + "featureType": "administrative", + "elementType": "labels", + "stylers": [ + { + "visibility": "on" + }, + { + "color": "#716464" + }, + { + "weight": "0.01" + } + ] + }, + { + "featureType": "administrative.country", + "elementType": "labels", + "stylers": [ + { + "visibility": "on" + } + ] + }, + { + "featureType": "landscape", + "elementType": "all", + "stylers": [ + { + "visibility": "simplified" + } + ] + }, + { + "featureType": "landscape.natural", + "elementType": "geometry", + "stylers": [ + { + "visibility": "simplified" + } + ] + }, + { + "featureType": "landscape.natural.landcover", + "elementType": "geometry", + "stylers": [ + { + "visibility": "simplified" + } + ] + }, + { + "featureType": "poi", + "elementType": "all", + "stylers": [ + { + "visibility": "simplified" + } + ] + }, + { + "featureType": "poi", + "elementType": "geometry.fill", + "stylers": [ + { + "visibility": "simplified" + } + ] + }, + { + "featureType": "poi", + "elementType": "geometry.stroke", + "stylers": [ + { + "visibility": "simplified" + } + ] + }, + { + "featureType": "poi", + "elementType": "labels.text", + "stylers": [ + { + "visibility": "simplified" + } + ] + }, + { + "featureType": "poi", + "elementType": "labels.text.fill", + "stylers": [ + { + "visibility": "simplified" + } + ] + }, + { + "featureType": "poi", + "elementType": "labels.text.stroke", + "stylers": [ + { + "visibility": "simplified" + } + ] + }, + { + "featureType": "poi.attraction", + "elementType": "geometry", + "stylers": [ + { + "visibility": "on" + } + ] + }, + { + "featureType": "road", + "elementType": "all", + "stylers": [ + { + "visibility": "on" + } + ] + }, + { + "featureType": "road.highway", + "elementType": "all", + "stylers": [ + { + "visibility": "off" + } + ] + }, + { + "featureType": "road.highway", + "elementType": "geometry", + "stylers": [ + { + "visibility": "on" + } + ] + }, + { + "featureType": "road.highway", + "elementType": "geometry.fill", + "stylers": [ + { + "visibility": "on" + } + ] + }, + { + "featureType": "road.highway", + "elementType": "geometry.stroke", + "stylers": [ + { + "visibility": "simplified" + }, + { + "color": "#a05519" + }, + { + "saturation": "-13" + } + ] + }, + { + "featureType": "road.local", + "elementType": "all", + "stylers": [ + { + "visibility": "on" + } + ] + }, + { + "featureType": "transit", + "elementType": "all", + "stylers": [ + { + "visibility": "simplified" + } + ] + }, + { + "featureType": "transit", + "elementType": "geometry", + "stylers": [ + { + "visibility": "simplified" + } + ] + }, + { + "featureType": "transit.station", + "elementType": "geometry", + "stylers": [ + { + "visibility": "on" + } + ] + }, + { + "featureType": "water", + "elementType": "all", + "stylers": [ + { + "visibility": "simplified" + }, + { + "color": "#84afa3" + }, + { + "lightness": 52 + } + ] + }, + { + "featureType": "water", + "elementType": "geometry", + "stylers": [ + { + "visibility": "on" + } + ] + }, + { + featureType: "poi.business", + elementType: "labels", + stylers: [ + { visibility: "off" } + ] + }, + { + "featureType": "water", + "elementType": "geometry.fill", + "stylers": [ + { + "visibility": "on" + } + ] + } +] + +module.exports = Styles diff --git a/app/javascript/legacy/components/modal.js b/app/javascript/legacy/components/modal.js new file mode 100644 index 00000000..a24d9c33 --- /dev/null +++ b/app/javascript/legacy/components/modal.js @@ -0,0 +1,9 @@ +// License: LGPL-3.0-or-later +const h = require('flimflam/h') +const modal = require('flimflam/ui/modal') + +// convenience wrapper for setting modal sizes +// sizes can be 'small' or 'large' +module.exports = (obj, size='medium') => + h('div', {class: {[`modal-${size}`] : size}}, [ modal(obj)]) + diff --git a/app/javascript/legacy/components/nonprofit-branding.js b/app/javascript/legacy/components/nonprofit-branding.js new file mode 100644 index 00000000..c343d6f5 --- /dev/null +++ b/app/javascript/legacy/components/nonprofit-branding.js @@ -0,0 +1,5 @@ +// License: LGPL-3.0-or-later +import nonprofitBranding from '../../../../javascripts/src/lib/nonprofitBranding'; + +module.exports = nonprofitBranding(app.nonprofit.brand_color) + diff --git a/app/javascript/legacy/components/number-input.js b/app/javascript/legacy/components/number-input.js new file mode 100644 index 00000000..da5ff94c --- /dev/null +++ b/app/javascript/legacy/components/number-input.js @@ -0,0 +1,17 @@ +// License: LGPL-3.0-or-later +const h = require('flimflam/h') +const classObject = require('../common/class-object') + +module.exports = (name, placeholder, value, classes) => { +return h('input.max-width-2', { + props: { + type: 'number' + , + , name + , placeholder + , value + } + , class: classObject(classes) + }) +} + diff --git a/app/javascript/legacy/components/progress-bar.js b/app/javascript/legacy/components/progress-bar.js new file mode 100644 index 00000000..952065b3 --- /dev/null +++ b/app/javascript/legacy/components/progress-bar.js @@ -0,0 +1,23 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const R = require('ramda') +const flyd = require('flyd') + +// A progress bar component +// Only a view function +// Simply pass in a state object, which should have: +// - hidden: Boolean (whether to display the bar) +// - percentage: Integer (percentage complete for the bar) +// - status: String (status message to display) +function view(state) { + if(state.hidden) return '' + return h('div.u-centered', [ + h('div.progressBar.u-marginY--10', [ + h('div.progressBar-fill--striped', {style: {width: state.percentage + '%'}}) + ]) + , h('p.status.u-marginTop--10', [ state.status ]) + ]) +} + +module.exports = view + diff --git a/app/javascript/legacy/components/public-activities.js b/app/javascript/legacy/components/public-activities.js new file mode 100644 index 00000000..46a2ce39 --- /dev/null +++ b/app/javascript/legacy/components/public-activities.js @@ -0,0 +1,64 @@ +// License: LGPL-3.0-or-later +const flyd = require('flyd') +const h = require('snabbdom/h') +const R = require('ramda') +const moment = require('moment') +const request = require('../common/request') + +// type can be 'campaign' or 'event' +const init = (type, path) => { + const resp$ = request({method: 'get', path}).load + const formattedResp$ = flyd.map(formatResp[type], resp$) + return {formattedResp$} +} + +const ago = date => moment(date).fromNow() +const formatRecurring = o => o.recurring + ? `made a recurring contribution of` + : `contributed` + +const formatCampaign = r => + R.map(o => { + return { + name: o.supporter_name + , action: formatRecurring(o) + ' ' + o.amount + , date: ago(o.date) + } + }, r.body) + +const formatEvent = r => + R.map(o => { + return { + name: o.supporter_name + , action: `got ${o.quantity} ticket${o.quantity > 1 ? 's' : ''}` + , date: ago(o.created_at) + } + }, r.body) + +const formatResp = { + campaign: formatCampaign +, event: formatEvent +} + +const activities = data => { + return h('tr', [ + h('td.u-padding--10.u-fontSize--13', [h('strong', data.name), data.action? h('div.u-marginTop--3', data.action) : '']) + , h('td.u-textAlign--right.u-fontSize--12.strong.u-paddingRight--10', [h('small', data.date)]) + ]) +} + +const view = state => { + if(app.hide_activities) return '' + const mixin = content => + h('section.pastelBox--grey', [h('header', 'Recent Activity'), content]) + if (!state.formattedResp$()) + return mixin(h('div.u-padding--15.u-centered.u-color--grey', 'Loading...')) + if (!state.formattedResp$().length) + return mixin(h('div.u-padding--15.u-centered.u-color--grey', 'None yet')) + return mixin(h('table.u-margin--0.table--striped', [ + h('tbody', R.map(activities, state.formattedResp$())) + ])) +} + +module.exports = {init, view} + diff --git a/app/javascript/legacy/components/radio-and-label-wrapper.js b/app/javascript/legacy/components/radio-and-label-wrapper.js new file mode 100644 index 00000000..1bcbb45f --- /dev/null +++ b/app/javascript/legacy/components/radio-and-label-wrapper.js @@ -0,0 +1,11 @@ +// License: LGPL-3.0-or-later +var h = require("virtual-dom/h") + +// a constructor function for creating radio-label pairs +module.exports = function(id, name, customAttributes, content, stream){ + var customAttributes = customAttributes ? customAttributes : {} + return [ + h('input', {type: 'radio', name: name, id: id, attributes: customAttributes, onclick: stream}), + h('label', {attributes: {'for': id}}, content) + ] +} diff --git a/app/javascript/legacy/components/radios.js b/app/javascript/legacy/components/radios.js new file mode 100644 index 00000000..7d53c9d9 --- /dev/null +++ b/app/javascript/legacy/components/radios.js @@ -0,0 +1,24 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const h = require('flimflam/h') +const uuid = require('uuid') + +// example: +// radios('frequency', [ +// {label: 'Monthly', checked: true} +// , {label: 'Quarterly'} +// , {label: 'Yearly'} +// ]) + +const radios = name => label => { + if(typeof label === 'string') label = {label: label} + const id = uuid.v1() + return h('div', [ + h('input', {props: {type: 'radio', id, name: name, value: label.label, checked: label.checked}}) + , h('label', {attrs: {for: id}},[h('span.sub.pl-1.font-weight-1', label.label)]) + ]) +} + +module.exports = (name, labels) => + h('div.no-padding-last-child', R.map(radios(name), labels)) + diff --git a/app/javascript/legacy/components/render-activities.js b/app/javascript/legacy/components/render-activities.js new file mode 100644 index 00000000..ecb265a6 --- /dev/null +++ b/app/javascript/legacy/components/render-activities.js @@ -0,0 +1,18 @@ +// License: LGPL-3.0-or-later +const snabbdom = require('snabbdom') +const render = require('ff-core/render') +const activities = require('./public-activities') + +module.exports = (type, path) => { + const init = _ => activities.init(type, path) + + const view = state => activities.view(state) + + const patch = snabbdom.init([ + require('snabbdom/modules/class') + , require('snabbdom/modules/props') + , require('snabbdom/modules/style') + ]) + render({state: init(), view, patch, container: document.querySelector('#js-activities')}) +} + diff --git a/app/javascript/legacy/components/saving_indicator.js b/app/javascript/legacy/components/saving_indicator.js new file mode 100644 index 00000000..8df1f142 --- /dev/null +++ b/app/javascript/legacy/components/saving_indicator.js @@ -0,0 +1,19 @@ +// License: LGPL-3.0-or-later +var h = require("virtual-dom/h") + +module.exports = function(savingState) { + return h('div.savingIndicator.pastelBox--yellow' + , {style: { + position: 'fixed' + , top: '0' + , left: '50%' + , width: '70px' + , marginLeft: '-35px' + , textAlign: 'center' + , zIndex: '999999' + , fontSize: '14px' + , padding: '4px' + , display: savingState.hide ? 'none' : 'block' + }} + , savingState.text) +} diff --git a/app/javascript/legacy/components/search-table.js b/app/javascript/legacy/components/search-table.js new file mode 100644 index 00000000..28c318fd --- /dev/null +++ b/app/javascript/legacy/components/search-table.js @@ -0,0 +1,43 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const h = require('flimflam/h') +const search = require('./search') + +const map = R.addIndex(R.map) + +const table = (data=[], header, row) => + h('table.width-full', R.concat(header, map(row, data))) + +const showMore = state => { + if(!state.hasMoreResults$()) return '' + return h('div.py-3.border-top.border-color-grey', [ + h('button', { + attrs: {disabled: state.loading$()} + , on: {click: [ state.searchLessQuery$, { + page: state.searchLessQuery$().page + 1 + , search: '' + , page_length: state.pageLength + }]}}, 'Show more') + ]) +} + +const searchForm = (state, placeholder) => + h('div.clearfix.py-3', [ + h('form.right', {on: {submit: state.submitSearch$}}, [ + search(state.loading$(), placeholder) + ]) + ]) + +const view = (state, header, row, placeholder) => { + return h('div', [ + h('div', [searchForm(state, placeholder)]) + , state.data$().length && state.data$()[0] + ? table(state.data$(), header, row) + : h('div.py-2.color-grey', state.loading$() ? 'Loading...' : 'No results') + , showMore(state) + , state.loading$() ? h('div.loader') : '' + ]) +} + +module.exports = view + diff --git a/app/javascript/legacy/components/search.js b/app/javascript/legacy/components/search.js new file mode 100644 index 00000000..cbdb33ef --- /dev/null +++ b/app/javascript/legacy/components/search.js @@ -0,0 +1,10 @@ +// License: LGPL-3.0-or-later +const h = require('flimflam/h') +const input = require('./text-input') + +module.exports = (loading, placeholder='Search') => + h('div.table', [ + h('div.middle-cell', [ input('', placeholder) ]) + , h('div.middle-cell.pl-1', [ h('button', {attrs: {disabled: loading}},'Search') ]) + ]) + diff --git a/app/javascript/legacy/components/select.js b/app/javascript/legacy/components/select.js new file mode 100644 index 00000000..a1b49a78 --- /dev/null +++ b/app/javascript/legacy/components/select.js @@ -0,0 +1,23 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const h = require('flimflam/h') + +// example: +// select({ +// name: 'contact' +// , options: ['email', 'SMS', 'phone'] +// , placeholder: 'How would you like to be contacted' +// , selected: 'email' +// }) + +const option = selected => o => + h('option', {props: {value: o, selected: selected && selected === o}}, o) + +module.exports = obj => + h('select', {props: {name: obj.name}} + , R.concat( + [h('option', {props: {disabled: 'true', selected: obj.selected === undefined}}, obj.placeholder || 'Select One')] + , R.map(option(obj.selected), obj.options) + ) + ) + diff --git a/app/javascript/legacy/components/sepa-form.es6 b/app/javascript/legacy/components/sepa-form.es6 new file mode 100644 index 00000000..ceeabae8 --- /dev/null +++ b/app/javascript/legacy/components/sepa-form.es6 @@ -0,0 +1,110 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const R = require('ramda') +const validatedForm = require('ff-core/validated-form') +const button = require('ff-core/button') +const flyd = require('flyd') +flyd.flatMap = require('flyd/module/flatmap') +flyd.filter = require('flyd/module/filter') +flyd.sampleOn = require('flyd/module/sampleon') +const scanMerge = require('flyd/module/scanmerge') +const IBAN = require('iban') + + +const request = require('../common/request') + +// Form validation constraints, validator functions, and error messages: +const constraints = { + name: {required: true} +, iban: {required: true, ibanFormat: /[a-zA-Z]{2}\d{14}/} +, bic: {required: true} +} + +const validators = { ibanFormat: IBAN.isValid } + +// You can pass in the .hideButton boolean if you want to control whether the submit button is shown/hidden +// Pass in a .card object, which can have default objects for the card form (name, number, cvc, exp_month, etc) +// Pass in .path to set the endpoint for saving the card +// Pass in .payload for default data to send to the server for every card save request (such as a request token) +const init = (state) => { + state = state || {} + + var messages = { + required: I18n.t('nonprofits.donate.payment.card.errors.field.presence') + , ibanFormat: I18n.t('nonprofits.donate.payment.card.errors.field.format') + } + + state.form = validatedForm.init({constraints, validators, messages}) + state.supp$ = flyd.sampleOn(state.form.validData$, state.supporter) + state.sepa$ = flyd.combine((supporter, sepaParams) => { + return {sepa_params: sepaParams(), supporter_id: supporter()} + }, [state.supp$, state.form.validData$]) + + const response$ = flyd.flatMap(saveTransferData, state.sepa$) + state.reponseOk$ = flyd.filter(response => !response.error, response$) + state.error$ = flyd.map(R.prop('error'), flyd.filter(response => response.error, state.reponseOk$)) + state.saved$ = flyd.filter(response => !response.error, state.reponseOk$) + + state.loading$ = scanMerge([ + [state.form.validSubmit$, R.always(true)] + , [state.error$, R.always(false)] + , [state.saved$, R.always(false)] + ], false) + + return state +} + +// Save transfer details to our own servers, and return a response stream +function saveTransferData(params){ + return flyd.map(R.prop('body'), request({ + method: 'post' + , path: '/sepa' + , send: params + }).load + ) +} + +// -- Virtual DOM + +const view = state => { + var field = validatedForm.field(state.form) + return validatedForm.form(state.form, h('form.sepaForm', [ + h('div.u-background--grey.group.u-padding--8', [ + nameInput(field) + , ibanInput(field) + , bicInput(field) + ]) + , h('div.u-centered', [ + button({ + error$: state.hideErrors ? flyd.stream() : state.error$ + , buttonText: I18n.t('nonprofits.donate.payment.card.submit') + , loadingText: ` ${I18n.t('nonprofits.donate.payment.card.loading')}` + , loading$: state.loading$ + }) + ]) + ]) + ) +} + +const nameInput = (field, name) => + h('fieldset', [ + field(h('input.u-margin--0', + { props: { name: 'name' , value: name || '', placeholder: I18n.t('nonprofits.donate.payment.sepa.name') } } + )) + ]) + +const ibanInput = field => + h('fieldset.col-12.u-margin--0', [ + field(h('input.u-margin--0', + {props: { type: 'text' , name: 'iban' , placeholder: I18n.t('nonprofits.donate.payment.sepa.iban') } } + )) + ]) + +const bicInput = field => + h('fieldset.col-right-0.u-margin--0', [ + field(h('input.u-margin--0.hidden', + { props: { name: 'bic' , type: 'hidden', value:'NOTPROVIDED', placeholder: I18n.t('nonprofits.donate.payment.sepa.bic') } } + )) + ]) + +module.exports = {view, init} diff --git a/app/javascript/legacy/components/set-state-from-value.js b/app/javascript/legacy/components/set-state-from-value.js new file mode 100644 index 00000000..6f3ed0ad --- /dev/null +++ b/app/javascript/legacy/components/set-state-from-value.js @@ -0,0 +1,18 @@ +// License: LGPL-3.0-or-later +module.exports = function (state, ev){ + var target = ev.target + var names = target.name.split('.') + var value = target.type === 'checkbox' ? target.checked : target.value + var nestedState = state + + for(var i = 0, len = names.length - 1; i < len; ++i) { + if(nestedState[names[i]] === undefined) return state + nestedState = nestedState[names[i]] + } + + var lastKey = names[names.length - 1] + + nestedState[lastKey] = value + + return state +} diff --git a/app/javascript/legacy/components/show-more-button.es6 b/app/javascript/legacy/components/show-more-button.es6 new file mode 100644 index 00000000..a6d37d58 --- /dev/null +++ b/app/javascript/legacy/components/show-more-button.es6 @@ -0,0 +1,35 @@ +// License: LGPL-3.0-or-later +/* +A 'Show More' button component, useful for placing at the bottom of a listing of ajax'd data. + +the showMore button's state uses the following properties: + +moreLoading: boolean (you might want general "loading" and a separate "moreLoading" states) +remaining: integer (count of how many results are not shown and still left. If 0, the show more button hides) +*/ + + +const view = require('vvvview') +const h = require("virtual-dom/h") +const flyd = require('flyd') + +var $ = { + nextPageClicks: flyd.stream() +} + +const root = (moreLoading, remaining) => { + var buttonContent = moreLoading + ? h('span', [h('i.fa.fa-spin.fa-spinner'), ' Loading... ']) + : h('span', ' Show More ') + + return h('div.moreResults.group', {style: {display: remaining ? 'block' : 'none'}}, [ + h('button.button--micro.details', {disabled: moreLoading, onclick: $.nextPageClicks}, [ + buttonContent, + ]), + ' ', + h('a.button--micro.details', {href: '#'}, 'Back to Top') + ]) +} + +module.exports = {root: root, $streams: $} + diff --git a/app/javascript/legacy/components/state-selector.js b/app/javascript/legacy/components/state-selector.js new file mode 100644 index 00000000..eecc4775 --- /dev/null +++ b/app/javascript/legacy/components/state-selector.js @@ -0,0 +1,25 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const R = require('ramda') + +const geo = require('../common/geography') +const stateCodes = geo.stateCodes + + +// Generate a drop +// +// options are +// { +// default: val // default value to be selected among the options +// , name: str // name attribute of the select +// } + +function view(options) { + var stateOptions = R.map( + s => h('option', {props: {value: s, selected: options.default === s}}, s) + , stateCodes + ) + return h('select', {props: {name: options.name }}, stateOptions) +} + +module.exports = view diff --git a/app/javascript/legacy/components/styles/branded-wizard.js b/app/javascript/legacy/components/styles/branded-wizard.js new file mode 100644 index 00000000..c22312f2 --- /dev/null +++ b/app/javascript/legacy/components/styles/branded-wizard.js @@ -0,0 +1,71 @@ +// License: LGPL-3.0-or-later +const colors = require('../nonprofit-branding') +const gradient = require('../../common/css-gradient') + +const bg = color => `background-color: ${color} !important;` + + +module.exports = _ => +` +.badge { + display: inline-block; + min-width: 10px; + padding: 3px 7px; + font-size: 11px; + font-weight: bold; + color: #fff; + line-height: 1; + vertical-align: middle; + white-space: nowrap; + text-align: center; + background-color: #9c9c9c; + border-radius: 10px; +} +.badge:empty { + display: none; +} + +button .badge { + position: relative; + top: -1px; +} + +.wizard-steps div.is-selected, +.wizard-steps button.is-selected { + ${bg(colors.lighter)} +} +.wizard-steps .button.white { + color: #494949; +} +.wizard-steps a:not(.button--small), +.ff-wizard-index-label.ff-wizard-index-label--accessible, +.wizard-index-label.is-accessible { + color: ${colors.dark} !important; +} +.wizard-steps input.is-selected { + border-color: ${colors.light} !important; +} +.wizard-steps button:not(.white):not([disabled]) { + ${bg(colors.dark)} +} +.wizard-steps .highlight { + ${bg(colors.lightest)} +} +.wizard-steps label, +.wizard-steps th { + color: #636363; +} + +.wizard-steps input[type='radio']:checked + label:before { + ${bg(colors.base)} +} + +.wizard-steps input[type='checkbox'] + label:before { + color: ${colors.base} !important; +} + +.ff-wizard-index-label.ff-wizard-index-label--current, +.wizard-index-label.is-current { + ${gradient('left', '#fbfbfb', colors.light)} +} +` \ No newline at end of file diff --git a/app/javascript/legacy/components/styles/render-styles.js b/app/javascript/legacy/components/styles/render-styles.js new file mode 100644 index 00000000..393cb43f --- /dev/null +++ b/app/javascript/legacy/components/styles/render-styles.js @@ -0,0 +1,9 @@ +// License: LGPL-3.0-or-later +module.exports = _ => { + var styleTag = document.createElement('style') + return styles => { + styleTag.innerHTML = styles + document.querySelector('head').appendChild(styleTag) + } +} + diff --git a/app/javascript/legacy/components/supporter-address-form.es6 b/app/javascript/legacy/components/supporter-address-form.es6 new file mode 100644 index 00000000..c30b849a --- /dev/null +++ b/app/javascript/legacy/components/supporter-address-form.es6 @@ -0,0 +1,88 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const R = require('ramda') +const flyd = require('flyd') +const button = require('ff-core/button') +const serializeForm = require('form-serialize') +flyd.scanMerge = require('flyd/module/scanmerge') +flyd.flatMap = require('flyd/module/flatmap') +flyd.filter = require('flyd/module/filter') +flyd.mergeAll = require('flyd/module/mergeall') +const request = require('../common/request') + +// pass in your existing supporter data to prefill the form +// pass in your url endpoint for supporter updates +// pass in some default data for your update payload +function init(state) { + state = state || {} + state = R.merge({ + submit$: flyd.stream() + , supporter$: flyd.stream(state.supporter || {}) + , error$: flyd.stream() + }, state) + + state.updated$ = flyd.map( + ev => { + ev.preventDefault() + return serializeForm(ev.target, {hash: true}) + } + , state.submit$ ) + + state.supporter$ = flyd.merge(state.updated$, flyd.stream(state.supporter)) + + state.response$ = flyd.flatMap( + supporter => flyd.map(R.prop('body'), request({ + method: 'put' + , path: state.path || `/nonprofits/${app.nonprofit_id}/supporters` + , send: R.merge({supporter}, state.payload || {}) + }).load) + , state.updated$ ) + + state.loading$ = flyd.mergeAll([ + flyd.map(R.always(true), state.submit$) + , flyd.map(R.always(false), state.response$) + ]) + + return state +} + +// things you need in state: +// - a supporter object with name, address, city, state_code, country, zip_code +// - the $submitAddressUpdate stream +function view(state) { + var supporter = state.supporter + return h('form', { on: {submit: state.submit$}}, [ + h('div.layout--three.u-marginBottom--10', [ + h('span', [ + h('label', 'Name') + , h('input', {props: {name: 'name', placeholder: 'name', value: supporter.name}}) + ]) + , h('span', [ + h('label', 'Street Address') + , h('input', {props: {name: 'address', placeholder: 'address', value: supporter.address}}) + ]) + , h('span', [ + h('label', 'City') + , h('input', {props: {name: 'city', placeholder: 'city', value: supporter.city}}) + ]) + , ]) + , h('div.layout--three.u-marginBottom--15', [ + h('span', [ + h('label', 'State/Region') + , h('input', {props: {name: 'state_code', placeholder: 'state/region', value: supporter.state_code}}) + ]) + , h('span', [ + h('label', 'Postal Code') + , h('input', {props: {name: 'zip_code', placeholder: 'postal code', value: supporter.zip_code}}) + ]) + , h('span', [ + h('label', 'Country') + , h('input', {props: {name: 'country', placeholder: 'country', value: supporter.country}}) + ]) + ]) + , h('input', {props: {type: 'hidden', name: 'id', value: supporter.id}}) + , button(R.pick(['loading$', 'error$'], state)) + ]) +} + +module.exports = {view, init} diff --git a/app/javascript/legacy/components/supporter-fields.js b/app/javascript/legacy/components/supporter-fields.js new file mode 100644 index 00000000..39c1d4c4 --- /dev/null +++ b/app/javascript/legacy/components/supporter-fields.js @@ -0,0 +1,170 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const R = require('ramda') +const flyd = require('flyd') +const geography = require('../common/geography') +const addressAutocomplete = require('./address-autocomplete-fields') + +// This component is just the fields without any form wrapper or submit button, which allows you to handle those pieces outside of here. + +function init(state, params$) { +//window.param$ = params$; //debug, make it global + state = state || {} + state = R.merge({ + selectCountry$: flyd.stream() + , supporter: R.merge({ + email: app.user ? app.user.email : undefined + }, R.pick(['first_name', 'last_name', 'phone', 'address', 'city', 'state_code', 'zip_code'], app.profile || {}) ) + , required: {} + }, state) + state.addressAutocomplete = addressAutocomplete.init({data$: flyd.stream(state.supporter)}, params$) + state.notUSA$ = flyd.mergeAll([ + flyd.stream(!app.show_state_field) + , flyd.map(select => !geography.isUSA(select.value), state.selectCountry$) + ]) + return state +} + +// Into state, pass: +// - to_ship (whether to show a "shipping address" message) +// - disallow_anonymous (nonprofit table has the 'no_anon' column) +// - autocomplete_supporter_address (nonprofit table has a corresponding column) +// - anonymous (Boolean) +// - first name +// - last name +// - phone +// - address +// - city +// - state_code +// - zip_code +// - profile_id +// - required: { (which fields to make required +// - name +// - email +// - address (will make address + city + state_code + zip_code all required) +// } +function view(state) { + const emailTitle = I18n.t('nonprofits.donate.info.supporter.email') + `${state.required.email ? `${I18n.t('nonprofits.donate.info.supporter.email_required')}` : ''}` + return h('div.u-marginY--10', [ + h('input', { props: { type: 'hidden' , name: 'profile_id' , value: state.supporter.profile_id } }) + , h('input', { props: { type: 'hidden' , name: 'nonprofit_id' , value: state.supporter.nonprofit_id || app.nonprofit_id } }) + , h('fieldset', [ + h('input.u-marginBottom--0', { + props: { + type: 'email' + , title: emailTitle + , name: 'email' + , required: state.required.email + , value: state.supporter.email + , placeholder: emailTitle + } + }) + ]) + , h('section.group', [ + h('fieldset.u-marginBottom--0.u-floatL.col-right-4', [ + h('input', { + props: { + type: 'text' + , name: 'first_name' + , placeholder: I18n.t('nonprofits.donate.info.supporter.first_name') + , required: state.required.first_name + , title: I18n.t('nonprofits.donate.info.supporter.first_name') + , value: state.supporter.first_name + } + }) + ]) + , h('fieldset.u-marginBottom--0.u-floatL.col-right-4', [ + h('input', { + props: { + type: 'text' + , name: 'last_name' + , placeholder: I18n.t('nonprofits.donate.info.supporter.last_name') + , required: state.required.last_name + , title: I18n.t('nonprofits.donate.info.supporter.last_name') + , value: state.supporter.last_name + } + }) + ]) + , h('fieldset.u-marginBottom--0.u-floatL.col-right-4', [ + h('input', { + props: { + type: 'text' + , name: 'phone' + , placeholder: I18n.t('nonprofits.donate.info.supporter.phone') + , title: I18n.t('nonprofits.donate.info.supporter.phone') + , required: state.required.phone + , value: state.supporter.phone + } + }) + ]) + ]) + , addressAutocomplete.view(state.addressAutocomplete) + ]) +} + +function manualAddressFields(state) { + state.selectCountry$ = state.selectCountry$ || flyd.stream() + var stateOptions = R.prepend( + h('option', {props: {value: '', disabled: true, selected: true}}, I18n.t('nonprofits.donate.info.supporter.state')) + , R.map( + s => h('option', {props: {selected: state.supporter.state_code === s, value: s}}, s) + , geography.stateCodes ) + ) + var countryOptions = R.prepend( + h('option', {props: {value: '', disabled: true, selected: true}}, I18n.t('nonprofits.donate.info.supporter.country')) + , R.map( + c => h('option', {props: {value: c[0]}}, c[1]) + , app.countriesList ) +) + return h('section.group.pastelBox--grey.u-padding--5', [ + state.to_ship ? h('label.u-centered.u-marginBottom--5', I18n.t('nonprofits.donate.info.supporter.shipping_address')) : '' + , h('fieldset.col-8.u-fontSize--14', [ + h('input.u-marginBottom--0', { + props: { + title: 'Address' + , placeholder: I18n.t('nonprofits.donate.info.supporter.address') + , type: 'text' + , name: 'address' + , value: state.supporter.address + } + }) + ]) + , h('fieldset.col-right-4.u-fontSize--14', [ + h('input.u-marginBottom--0', { + props: { + name: 'city' + , type: 'text' + , placeholder: I18n.t('nonprofits.donate.info.supporter.city') + , title: 'City' + , value: state.supporter.city + } + }) + ]) + , state.notUSA$() + ? showRegionField() + : h('fieldset.u-marginBottom--0.u-floatL.col-4', [ + h('select.select.u-fontSize--14.u-marginBottom--0', {props: {name: 'state_code'}}, stateOptions) + ]) + , h('fieldset.u-marginBottom--0.u-floatL.col-right-4.u-fontSize--14', [ + h('input.u-marginBottom--0', { + props: {type: 'text', title: 'Postal code', name: 'zip_code', placeholder: I18n.t('nonprofits.donate.info.supporter.postal_code'), value: state.supporter.zip_code} + }) + ]) + , h('fieldset.u-marginBottom--0.u-floatL.col-right-8', [ + h('select.select.u-fontSize--14.u-marginBottom--0', { + props: { name: 'country' } + , on: {change: ev => state.selectCountry$(ev.currentTarget)} + }, countryOptions ) + ]) + ]) +} + +function showRegionField() { + if(app.show_state_field) { + h('input.u-marginBottom--0.u-floatL.col-4', {props: {type: 'text', title: 'Region', name: 'region', placeholder: I18n.t('nonprofits.donate.info.supporter.region'), value: state.supporter.state_code}}) + } else { + return "" + } +} + +module.exports = {view, init} diff --git a/app/javascript/legacy/components/tables/filtering/apply_filter.js b/app/javascript/legacy/components/tables/filtering/apply_filter.js new file mode 100644 index 00000000..90b523aa --- /dev/null +++ b/app/javascript/legacy/components/tables/filtering/apply_filter.js @@ -0,0 +1,131 @@ +// License: LGPL-3.0-or-later +var format = require('../../../common/format') + +module.exports = function(scope) { + + appl.def(scope + '.filter_count', 0) + + var readable_keys = { + total_raised_greater_than: 'total contributed', + total_raised_less_than: 'total contributed', + last_payment_before: 'last payment', + location: 'location', + after_date: 'date', + before_date: 'date', + sort_date: 'date', + year: 'year', + sort_name: 'name', + campaign_id: 'campaign', + event_id: 'event', + sort_towards: 'towards', + sort_contributed: 'total contributed', + sort_last_payment: 'last payment', + sort_type: 'type', + sort_amount: 'amount', + amount_less_than: 'amount less than', + amount_greater_than: 'amount greater than', + amount: 'amount' + , has_contributed_during: 'contributed after' + , has_not_contributed_during: 'not contributed after' + , donation_type: 'payment type' + , recurring: 'recurring donors' + , tags: 'tags' + , notes: 'notes' + , custom_fields: 'custom fields' + } + + appl.def('readable_filter_names', function() { + var arr = [] + var q = appl[scope].query + for(var key in q) { + var name = readable_keys[key] + if(name && q[key] && q[key].length) arr.push(name) + } + return utils.uniq(arr).map(function(s) { return "" + s + "" }).join(' ') + }) + + appl.def('clear_all_filters', function() { + for(var key in appl[scope].query) { + appl.def(scope + '.query.' + key, '') + } + appl[scope].index() + document.querySelector('.filterPanel').reset() + $('.sortArrows').attr('sort', 'none') + appl.def(scope + '.filter_count', 0) + }) + + appl.def('apply_input_filter', function(name, val) { + if(val && !appl[scope].query[name]) { + appl.incr(scope + '.filter_count') + } else if(!val && appl[scope].query[name]) { + appl.decr(scope + '.filter_count') + } + appl.def(scope + '.query.' + name, val) + re_fetch() + }) + + appl.def('apply_sort_filter', function(name) { + var old_val = appl[scope].query[name] + if(!old_val || old_val === '') { + appl.incr(scope + '.filter_count') + appl.def(scope + '.query.' + name, 'desc') + } else if(old_val === 'asc') { + appl.decr(scope + '.filter_count') + appl.def(scope + '.query.' + name, '') + } else { + appl.def(scope + '.query.' + name, 'asc') + } + re_fetch() + }) + + appl.def('apply_checkbox_filter', function(el) { + el = appl.prev_elem(el) + var prop = scope + ".query." + el.name + if(el.checked) { + appl.incr(scope + '.filter_count') + appl.def(prop, 'true') + } else { + appl.decr(scope + '.filter_count') + appl.def(prop, '') + } + re_fetch() + }) + + // Instead of having checkboxes mark a single property as true/false, we want + // to have many checked checkboxes with the same name attribute get their + // values aggregated into a single array under the single property name. + // Eg. for tag filtering, we want the checked checkboxes to construct a + // single array of tag names. + appl.def('apply_checkbox_array_aggregator', function(el) { + el = appl.prev_elem(el) + var prop = scope + '.query.' + el.name + var array = appl[scope]['query'][el.name] || [] + if(el.checked) { + appl.incr(scope + '.filter_count') + array.push(el.value) + } else { + appl.decr(scope + '.filter_count') + array.splice(array.indexOf(el.value), 1) // Remove the tag name from the array + } + appl.def(prop, array) + re_fetch() + }) + + appl.def('apply_radio_filter', function(el) { + el = appl.prev_elem(el) + if(el.checked) { + var prop = scope + ".query." + el.name + if(el.value && !appl[prop]) appl.incr(scope + '.filter_count') + if(!el.value) appl.decr(scope + '.filter_count') + appl.def(prop, el.value) + re_fetch() + } + }) + + + function re_fetch() { + appl.def(scope + '.query.page', 1) + appl[scope].index() + } + +} // module.exports diff --git a/app/javascript/legacy/components/tables/search.es6 b/app/javascript/legacy/components/tables/search.es6 new file mode 100644 index 00000000..cff5ecdb --- /dev/null +++ b/app/javascript/legacy/components/tables/search.es6 @@ -0,0 +1,51 @@ +// License: LGPL-3.0-or-later +const h = require('virtual-dom/h') +const thunk = require('vdom-thunk') +const formToObj = require('../../common/form-to-object') +const flyd = require('flyd') +const filterStream = require('flyd/module/filter') + +// Uses an immutable state object with 'page' and 'search' keys +// Will also use a 'loading' key for loading animations +// Optionally pass in a 'placeholder' key for the placeholder text + +// Searches are streams of objects like {page: 1, search: 'xxyy'} + +// Whenever they blank out the search field, immediately re-request +var $searchKeyups = flyd.stream() +var $clearOuts = flyd.map( + () => ({page: 1, search: ''}), + filterStream( + ev => !ev.target.value.length, // all blank values + $searchKeyups)) + +// Search form submissions +var $searchSubmits = flyd.stream() +flyd.on(ev => ev.preventDefault(), $searchSubmits) +var $searches = flyd.merge( + $clearOuts, + flyd.map( + ev => formToObj(ev.target), + $searchSubmits)) + + +const root = state => + h('form.table-meta-search', { + onsubmit: $searchSubmits + }, [ + h('input', {type: 'hidden', name: 'page', value: 1}), + h('input', { + type: 'text', + name: 'search', + placeholder: state.get('placeholder') || 'Search', + value: state.get('search'), + onkeyup: $searchKeyups, + }), + h('button.button--input', {type: 'submit', disabled: state.get('loading')}, [ + h('i.fa.fa-search', {style: {display: state.get('loading') ? 'none' : ''}}), + h('i.fa.fa-spin.fa-spinner', {style: {display: state.get('loading') ? '' : 'none'}}) + ]) + ]) + +module.exports = {root: root, $streams: {searches: $searches}} + diff --git a/app/javascript/legacy/components/text-input.js b/app/javascript/legacy/components/text-input.js new file mode 100644 index 00000000..75cf386d --- /dev/null +++ b/app/javascript/legacy/components/text-input.js @@ -0,0 +1,15 @@ +// License: LGPL-3.0-or-later +const h = require('flimflam/h') +const classObject = require('../common/class-object') + +module.exports = (name, placeholder, value, classes) => { +return h('input.max-width-2', { + props: { + type: 'text' + , name + , placeholder + , value + } + , class: classObject(classes) + }) +} diff --git a/app/javascript/legacy/components/textarea.js b/app/javascript/legacy/components/textarea.js new file mode 100644 index 00000000..cd00057c --- /dev/null +++ b/app/javascript/legacy/components/textarea.js @@ -0,0 +1,15 @@ +// License: LGPL-3.0-or-later +const h = require('flimflam/h') +const classObject = require('../common/class-object') + +module.exports = (name, placeholder, value, classes) => { +return h('textarea.max-width-2', { + props: { + name + , placeholder + , value + } + , class: classObject(classes) + }) +} + diff --git a/app/javascript/legacy/components/todos.js b/app/javascript/legacy/components/todos.js new file mode 100644 index 00000000..57637d41 --- /dev/null +++ b/app/javascript/legacy/components/todos.js @@ -0,0 +1,26 @@ +// License: LGPL-3.0-or-later +module.exports = function(cb){ + var request = require('../common/client') + var url = '/nonprofits/' + app.nonprofit_id + + appl.def('todos.loading', true) + + // data returns booleans + request.get(url + appl.todos_action).end(function(err, resp) { + if(!resp.ok) return + var data = resp.body + + cb(data, url) + + appl.def('todos.loading', false) + appl.def('todos.percent_done', todos_percentage()) + }) + + function todos_percentage() { + var finished_todos = 0 + appl.todos.items.forEach(function(item){ + if(item.done) finished_todos += 1 + }) + return Math.floor(finished_todos / appl.todos.items.length * 100) + } +} diff --git a/app/javascript/legacy/components/top-nav.js b/app/javascript/legacy/components/top-nav.js new file mode 100644 index 00000000..2e958b2f --- /dev/null +++ b/app/javascript/legacy/components/top-nav.js @@ -0,0 +1,15 @@ +// License: LGPL-3.0-or-later +const h = require('flimflam/h') + +module.exports = title => + h('div.bg-grey-2', [ + h('div.container.px-2.py-1.table.width-full', [ + h('h4.m-0.middle-cell.py-1', title) + // , h('div.middle-cell.content-width.color-blue', [ + // h('i.h3.m-icon.middle-cell', 'account_circle') + // , h('small.middle-cell.px-1', 'Nonprofit name') + // , h('i.h4.m-icon.middle-cell', 'keyboard_arrow_down') + // ]) + ]) + ]) + diff --git a/app/javascript/legacy/components/wizard.js b/app/javascript/legacy/components/wizard.js new file mode 100644 index 00000000..e87f1c67 --- /dev/null +++ b/app/javascript/legacy/components/wizard.js @@ -0,0 +1,59 @@ +// License: LGPL-3.0-or-later +// Functionality for a wizard UI (eg. our donate button) + +appl.def('wizard', { + + set_step: function(wiz_name, step_name, el) { + appl.push({name: step_name, el: appl.prev_elem(el)}, wiz_name + '.steps') + }, + + show_step: function(wiz_name, index) { + var steps = appl[wiz_name].steps + steps.forEach(function(step) { step.el.style.display = 'none' }) + appl[wiz_name].steps[index].el.style.display = 'table-cell' + }, + + init: function(wiz_name, node) { + appl.def(wiz_name + '.current_step', 0) + appl[wiz_name].steps[0].is_accessible = true + appl.trigger_update(wiz_name + '.steps') + this.show_step(wiz_name, 0) + appl.prev_elem(node).style.display = 'table' + return appl + }, + + reset: function(wiz_name) { + var wiz = appl[wiz_name] + wiz.steps = wiz.steps.map(function(step) { + $(step.el).find('form').each(function() { this.reset() }) + step.is_accessible = false + return step + }) + wiz.steps[0].is_accessible = true + appl.trigger_update(wiz_name + '.steps') + appl.def(wiz_name + '.current_step', 0) + appl.wizard.show_step(wiz_name, 0) + return appl + }, + + jump: function(wiz_name, index) { + var wiz = appl[wiz_name] + if(!wiz.steps[index].is_accessible) return + appl.def(wiz_name + '.current_step', index) + this.show_step(wiz_name, index) + return appl + }, + + advance: function(wiz_name) { + var wiz = appl[wiz_name] + if(wiz.current_step + 1 >= wiz.steps.length) + wiz.on_complete() + appl.incr(wiz_name + '.current_step') + wiz.steps[wiz.current_step].is_accessible = true + appl.trigger_update(wiz_name + '.steps') + appl.wizard.show_step(wiz_name, wiz.current_step) + return appl + } + +}) + diff --git a/app/javascript/legacy/donations/create.js b/app/javascript/legacy/donations/create.js new file mode 100644 index 00000000..6bdbce09 --- /dev/null +++ b/app/javascript/legacy/donations/create.js @@ -0,0 +1,49 @@ +// License: LGPL-3.0-or-later +// This defines a create_donation function that will create a Donation and +// Charge in our database and on Stripe given a Supporter that has a valid Card +// +// Use this with the cards/fields.html.erb partial +// +// Call it like: create_donation(card_obj, donation_obj) +// where card object is the full card data (name, number, expiry, etc) from the cards/fields partial +// and donation_obj is all the donation data (amount, type, etc) +// +// This function will create a Donation if donation.recurring is falsy +// It will create a RecurringDonation if donation.recurring is true + +var create_card = require('../cards/create') +var format_err = require('../common/format_response_error') +var format = require('../common/format') +var request = require('../common/super-agent-promise') + +module.exports = create_donation + + +function create_donation(donation) { + if(donation.recurring_donation) { + var path = '/nonprofits/' + app.nonprofit_id + '/recurring_donations' + } else { + var path = '/nonprofits/' + app.nonprofit_id + '/donations' + } + if(donation.dollars) { + donation.amount = format.dollarsToCents(donation.dollars) + delete donation.dollars + } + return request.post(path).set('Content-Type', 'application/json').send( donation).perform() + // Reset the card form ui + .then(function(resp) { + appl.def('card_form', {status: '', error: false}) + return resp.body + }) + // Display any errors + .catch(function(resp) { + appl.def('card_form', { + loading: false, + error: true, + status: format_err(resp), + progress_width: '0%' + }) + throw new Error(resp) + }) +} + diff --git a/app/javascript/legacy/donations/create_offline.js b/app/javascript/legacy/donations/create_offline.js new file mode 100644 index 00000000..292357c9 --- /dev/null +++ b/app/javascript/legacy/donations/create_offline.js @@ -0,0 +1,24 @@ +// License: LGPL-3.0-or-later +var request = require('../common/super-agent-promise') +var format = require('../common/format') + +module.exports = create_offsite_donation + +function create_offsite_donation(data, ui) { + ui.start() + if(data.dollars) { + data.amount = format.dollarsToCents(data.dollars) + delete data.dollars + } + if(data.date) data.date = format.date.toStandard(data.date) + return request.post('/nonprofits/' + app.nonprofit_id + '/donations/create_offsite') + .send({donation: data}).perform() + .then(function(resp) { + ui.success(resp) + return resp + }) + .catch(function(resp) { + ui.fail(resp) + throw new Error(resp) + }) +} diff --git a/app/javascript/legacy/events/discounts/index.js b/app/javascript/legacy/events/discounts/index.js new file mode 100644 index 00000000..267931b5 --- /dev/null +++ b/app/javascript/legacy/events/discounts/index.js @@ -0,0 +1,37 @@ +// License: LGPL-3.0-or-later +var request = require('../../common/client') +var R = require('ramda') + +appl.def('discounts.url', '/nonprofits/' + app.nonprofit_id + '/events/' + appl.event_id + '/event_discounts') + +appl.def('discounts.index', function(){ + request.get(appl.discounts.url).end(function(err, resp) { + appl.def('discounts.data', resp.body || []) + }) +}) + +appl.discounts.index() + +appl.def('discounts.apply', function(node){ + var code = appl.prev_elem(node).value + var codes = R.pluck('code', appl.discounts.data) + if (!R.contains(code, codes)) { + appl.def('ticket_wiz.discounted_total_amount', false) + return + } + var discount_obj = R.find(R.propEq('code', code), appl.discounts.data) + var discount_mult = Number(discount_obj.percent) / 100 + var ticket_price = appl.ticket_wiz.total_amount + var discounted_ticket_price = ticket_price - Math.round(ticket_price * discount_mult) + if(discounted_ticket_price === 0){ + appl.def('ticket_wiz.post_data.kind', 'free') + } + appl.notify('Discount successfully applied') + appl.def('ticket_wiz.discounted_total_amount', discounted_ticket_price) + appl.def('ticket_wiz.post_data.event_discount_id', discount_obj.id) +}) + +if(app.current_event_editor) { + require('./manage') +} + diff --git a/app/javascript/legacy/events/discounts/manage.js b/app/javascript/legacy/events/discounts/manage.js new file mode 100644 index 00000000..96ab988d --- /dev/null +++ b/app/javascript/legacy/events/discounts/manage.js @@ -0,0 +1,93 @@ +// License: LGPL-3.0-or-later +var R = require('ramda') +var request = require('../../common/client') +var format = require('../../common/format') + +appl.def('discounts.create_or_update', function(form_obj, node){ + appl.def('discounts.loading', true) + if(!validate(form_obj)) { + appl.def('discounts.loading', false) + return + } + if(form_obj.id) { + update_discount(form_obj) + } else { + delete form_obj.id + create_discount(form_obj) + } +}) + + +appl.def('discounts.show_new', function(){ + appl.def('discounts.editing', {id: '', name: '', percent: '', code: ''}) + appl.open_modal('createOrEditDiscountsModal') +}) + + +appl.def('discounts.show_edit', function(i){ + appl.def('discounts.editing', appl.discounts.data[i]) + appl.open_modal('createOrEditDiscountsModal') +}) + + +function update_discount(form_obj){ + request.put(appl.discounts.url + '/' + form_obj.id, form_obj) + .end(function(err, resp){ + after_create_or_edit("Discount successfully edited") + }) +} + + +appl.def('discounts.delete', function(id){ + request.del(appl.discounts.url + '/' + id).end(function(err, resp) { + appl.notify('Discount successfully deleted') + appl.discounts.index() + }) +}) + + +function create_discount(form_obj){ + request.post(appl.discounts.url, form_obj) + .end(function(err, resp){ + after_create_or_edit("Discount successfully added") + }) +} + + +function after_create_or_edit(message){ + appl.discounts.index() + appl.notify(message) + appl.open_modal("manageDiscountsModal") + appl.def('discounts.loading', false) +} + + +function validate(form_obj){ + var blanks =['name', 'percent', 'code'] + var message = '' + blanks.map(function(a, i) { + if(!form_obj[a]) { message += format.capitalize(a) + ', '} + }) + if (message) { + appl.notify(message + " can't be blank") + return false + } + var percent = Number(form_obj.percent) + if (!Boolean(percent) || percent <= 0) { + appl.notify("Percentage must be a number larger than 0") + return false + } + if(percent > 100) { + appl.notify("Percentage can't be more than 100") + return false + } + var codes = R.pluck('code', R.reject(function(x){ return x['id'] === Number(form_obj.id)}, appl.discounts.data)) + var hasDupeCodes = R.contains(form_obj.code, codes) + + if (hasDupeCodes){ + appl.notify("That code is already being used for this event. Please type another code.") + return false + } + return form_obj +} + diff --git a/app/javascript/legacy/events/index/page.js b/app/javascript/legacy/events/index/page.js new file mode 100644 index 00000000..459d6b1f --- /dev/null +++ b/app/javascript/legacy/events/index/page.js @@ -0,0 +1,8 @@ +// License: LGPL-3.0-or-later +const renderListings = require('../listings') +renderListings(`/nonprofits/${app.nonprofit_id}/events/listings`) + +if(app.current_user) { + require('../../events/new/wizard') +} + diff --git a/app/javascript/legacy/events/listing-item/index.js b/app/javascript/legacy/events/listing-item/index.js new file mode 100644 index 00000000..c9832b22 --- /dev/null +++ b/app/javascript/legacy/events/listing-item/index.js @@ -0,0 +1,64 @@ +// License: LGPL-3.0-or-later +const format = require('../../common/format') +const h = require('snabbdom/h') +const moment = require('moment-timezone') + +const dateTime = (startTime, endTime) => { + const tz = ENV.nonprofitTimezone || 'America/Los_Angeles' + startTime = moment(startTime).tz(tz) + endTime = moment(endTime).tz(tz) + const sameDate = startTime.format("YYYY-MM-DD") === endTime.format("YYYY-MM-DD") + const ended = moment() > endTime ? ' (ended)' : '' + const format = 'MM/DD/YYYY h:mma' + const endTimeFormatted = sameDate ? endTime.format("h:mma") : endTime.format(format) + + return [ + h('strong', startTime.format(format) + ' - ' + endTimeFormatted) + , h('span.u-color--grey', ended) + ] +} + +const commaSeperate = arr => arr.filter(Boolean).join(', ') + +const metric = (label, val) => + h('span.u-inlineBlock.u-marginRight--20', [h('strong', `${label}: `), val || '0']) + +const row = (icon, content) => + h('tr', [ + h('td.u-centered', [h(`i.fa.${icon}`)]) + , h('td.u-padding--10', content) + ]) + +module.exports = e => { + const path = `/nonprofits/${app.nonprofit_id}/events/${e.id}` + const location = [ + h('p.strong.u-margin--0', e.venue_name) + , h('p.u-margin--0', commaSeperate([e.address, e.city, e.state_code, e.zip_code])) + ] + const attendeesMetrics = [ + metric('Attendees', e.total_attendees) + , metric('Checked In', e.checked_in_count) + , metric('Percent Checked In', Math.round((e.checked_in_count || 0) * 100 / (e.total_attendees || 0)) + '%') + ] + const moneyMetrics = [ + metric('Ticket Payments', '$' + format.centsToDollars(e.tickets_total_paid)) + , metric('Donations', '$' + format.centsToDollars(e.donations_total_paid)) + , metric('Total', '$' + format.centsToDollars(e.total_paid)) + ] + const links = [ + h('a.u-marginRight--20', {props: {href: path, target: '_blank'}}, 'Event Page') + , h('a', {props: {href: path + '/tickets', target: '_blank'}}, 'Attendees Page') + ] + return h('div.u-paddingTop--10.u-marginBottom--20', [ + h('h5.u-paddingX--20', e.name) + , h('table.table--striped.u-margin--0', [ + row('fa-clock-o', dateTime(e.start_datetime, e.end_datetime)) + , row('fa-map-marker', location) + , row('fa-users', attendeesMetrics) + , row('fa-dollar', moneyMetrics) + , row('fa-user', [h('strong', 'Organizer: '), e.organizer_email || 'None']) + , row('fa-link', links) + ]) + ]) +} + diff --git a/app/javascript/legacy/events/listings/index.js b/app/javascript/legacy/events/listings/index.js new file mode 100644 index 00000000..eb942a36 --- /dev/null +++ b/app/javascript/legacy/events/listings/index.js @@ -0,0 +1,57 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const h = require('snabbdom/h') +const flyd = require('flyd') +const render = require('ff-core/render') +const snabbdom = require('snabbdom') + +const request = require('../../common/request') +const listing = require('../listing-item') + +module.exports = pathPrefix => { + const get = param => { + const path = `${pathPrefix}?${param}=t` + return request({path, method: 'get'}).load + } + + const init = _ => { + return { + active: get('active') + , past: get('past') + , unpublished: get('unpublished') + , deleted: get('deleted') + } + } + + const listings = (key, state) => { + const resp$ = state[key] + const mixin = content => + h('section.u-marginBottom--30', [ + h('h5.u-centered.u-marginBottom--20', key.charAt(0).toUpperCase() + key.slice(1) + ' Events') + , h(`div.fundraiser--${key}`, content) + ]) + if(!resp$()) + return mixin([h('p.u-padding--15', 'Loading...')]) + if(!resp$().body.length) + return mixin([h('p.u-padding--15', `No ${key} events`)]) + return mixin(R.map(listing, resp$().body)) + } + + const view = state => + h('div', [ + listings('active', state) + , listings('past', state) + , listings('unpublished', state) + , listings('deleted', state) + ]) + + const container = document.querySelector('#js-eventsListing') + + const patch = snabbdom.init([ + require('snabbdom/modules/class') + , require('snabbdom/modules/props') + ]) + + render({ patch, container , view, state: init() }) +} + diff --git a/app/javascript/legacy/events/new/wizard.js b/app/javascript/legacy/events/new/wizard.js new file mode 100644 index 00000000..d63fdedc --- /dev/null +++ b/app/javascript/legacy/events/new/wizard.js @@ -0,0 +1,59 @@ +// License: LGPL-3.0-or-later +require('../../common/pikaday-timepicker') +require('../../components/wizard') +require('../../common/image_uploader') +var checkName = require('../../common/ajax/check_campaign_or_event_name') +var format_err = require('../../common/format_response_error') + + +appl.def('advance_event_name_step', function(form_obj) { + var name = form_obj['event[name]'] + checkName(name, 'event', function(){ + appl.def('new_event', form_obj) + appl.wizard.advance('new_event_wiz') + }) +}) + +// Post a new event. +appl.def('create_event', function(el) { + var form_data = utils.toFormData(appl.prev_elem(el)) + form_data = utils.mergeFormData(form_data, appl.new_event) + appl.def('new_event_wiz.loading', true) + + post_event(form_data) + .then(function(req) { + appl.notify("Redirecting to your new event...") + appl.redirect(JSON.parse(req.response).url) + }) + .catch(function(req) { + appl.def('new_event_wiz.loading', false) + appl.def('new_event_wiz.error', req.responseText) + }) +}) + + +// Using the bare-bones XMLHttpRequest API so we can post form data and upload the image +function post_event(form_data) { + return new Promise(function(resolve, reject) { + var req = new XMLHttpRequest() + req.open("POST", '/nonprofits/' + app.nonprofit_id + '/events') + req.setRequestHeader('X-CSRF-Token', window._csrf) + req.send(form_data) + req.onload = function(ev) { + if(req.status === 200) resolve(req) + else reject(req) + } + }) +} + + +// Pikaday and timepicker initialization nonsense + +var Pikaday = require('pikaday') +var moment = require('moment') +new Pikaday({ + field: document.querySelector('#date-string-input'), + format: 'M/D/YYYY', + minDate: moment().toDate() +}) + diff --git a/app/javascript/legacy/events/show/editor.js b/app/javascript/legacy/events/show/editor.js new file mode 100644 index 00000000..0952b4fc --- /dev/null +++ b/app/javascript/legacy/events/show/editor.js @@ -0,0 +1,43 @@ +// License: LGPL-3.0-or-later +// Functionality for Event Editors (a nonprofit admin, the event creator, or a super admin) + +require('../../common/image_uploader') + +const dupeIt = require('../../components/duplicate_fundraiser') + +var prefix = `/nonprofits/${app.nonprofit_id}/events` + +// takes prefix and fundraiser id +dupeIt(prefix, app.event_id) + +var url = `${prefix}/${app.event_id}` +var confirmation = require('../../common/confirmation') +var Pikaday = require('pikaday') +var moment = require('moment') + +require('../../components/ajax/toggle_soft_delete')(url, 'event') + +new Pikaday({ + field: document.querySelector('.date-picker'), + format: 'M/D/YYYY', + minDate: app.event_date || moment().toDate() +}) + +var editable = require('../../common/editable') + +editable($('#js-eventDescription'), { + sticky: true, + placeholder: "Add any event related text, images, videos or custom HTML here. We strongly recommend that this section is filled out with at least 250 words. It will be saved automatically as you type." +}) + +editable($('#js-customReceipt'), { + button: ["bold", "italic", "formatBlock", "align", "createLink", + "insertImage", "insertUnorderedList", "insertOrderedList", + "undo", "redo", "insert_donate_button", "html"] + , placeholder: "Add optional message here. It will be saved automatically as you type." +}) + + +appl.def('remove_this_image', function() { + appl.remove_background_image(url, 'event') +}) diff --git a/app/javascript/legacy/events/show/event_donation.js b/app/javascript/legacy/events/show/event_donation.js new file mode 100644 index 00000000..12cc64a5 --- /dev/null +++ b/app/javascript/legacy/events/show/event_donation.js @@ -0,0 +1,27 @@ +// License: LGPL-3.0-or-later +$('.ticket-level').click(function(e) { + wiz.model.set('single_amount', $(this).data('dollars')) + wiz.model.set('designation', $(this).data('name')) + wiz.model.set('description', $(this).data('desc')) + wiz.ticket_level_id = $(this).data('id') + wiz.donation.set({ + amount: $(this).data('amount'), + designation: $(this).data('name') + }) +}) + +$('.nonprofit-donate-button').click(function() { + wiz.model.set('single_amount', undefined) + wiz.model.set('designation', undefined) + wiz.model.set('description', undefined) + wiz.ticket_level_id = undefined + wiz.donation.set({ + amount: undefined, + designation: undefined + }) +}) + +$('.anon-wrapper').hide() +$('.info-submit').text('Submit') + +module.exports = wiz diff --git a/app/javascript/legacy/events/show/page.js b/app/javascript/legacy/events/show/page.js new file mode 100644 index 00000000..29c44ac7 --- /dev/null +++ b/app/javascript/legacy/events/show/page.js @@ -0,0 +1,111 @@ +// License: LGPL-3.0-or-later +require('../../common/pikaday-timepicker') +require('../../common/fundraiser_metrics') +require('../../components/fundraising/add_header_image') +require('../../tickets/new') +require('../../ticket_levels/manage') +require('../discounts/index') +require('../../common/on-change-sanitize-slug') +const donateWiz = require('../../nonprofits/donate/wizard') +const snabbdom = require('snabbdom') +const h = require('snabbdom/h') +const flyd = require('flyd') +const R = require('ramda') +const render = require('ff-core/render') +const modal = require('ff-core/modal') +const noScroll = require('no-scroll') + +const on_ios11 = require('../../common/on-ios11') + +function createClickListener(startWiz$){ + return (...props) => { + if (on_ios11()) + { + noScroll.on() + } + startWiz$(...props) + } + + +} + +// -- Flim flam root component for event pages +function init() { + var state = { } + const startWiz$ = flyd.stream() + const donateButtons = document.querySelectorAll('.js-openDonationModal') + R.map(x => x.addEventListener('click', createClickListener(startWiz$)), donateButtons) + state.modalID$ = flyd.map(R.always('donationModal'), startWiz$) + flyd.on((id) => { + if (on_ios11() && id ===null ){ + noScroll.off() + }}, state.modalID$) + flyd.on((id) => { + if (on_ios11() && id !==null){ + noScroll.on() + }}, state.modalID$) + state.donateWiz = donateWiz.init(flyd.stream({event_id: app.event_id})) + return state +} + +function view(state) { + return h('div', [ + h('div.donationModal', [ + modal({ + thisID: 'donationModal' + , id$: state.modalID$ + , body: donateWiz.view(state.donateWiz) + }) + ]) + ]) +} + +// -- Render to page +const patch = snabbdom.init([ + require('snabbdom/modules/eventlisteners') +, require('snabbdom/modules/class') +, require('snabbdom/modules/props') +, require('snabbdom/modules/style') +]) +render({state: init(), view, patch, container: document.querySelector('#js-main')}) + +const renderActivities = require('../../components/render-activities') + +if(!app.hide_activities) { + renderActivities('event', `/nonprofits/${app.nonprofit_id}/events/${app.event_id}/activities`) +} + +// -- Legacy viewscript stuff + + +if (app.nonprofit.brand_color) { + require('../../components/branded_fundraising') +} + +var request = require('../../common/client') +var path = '/nonprofits/' + app.nonprofit_id + '/events/' + app.event_id + + +if(app.current_event_editor) { + require('./editor') + require('./tour') + var create_info_card = require('../../supporters/info-card.es6') +} + + +// Event metrics init (total raised, total attendees) +appl.def('metrics.path_prefix', path + '/') +appl.ajax_metrics.index() + + +appl.ticket_wiz.on_complete = function(tickets) { + appl.ajax_metrics.index() +} + +appl.def('donate_wiz.donation.event_id', appl.event_id) + +appl.def('remove_event', function(e) { + request.del(path).end(function(err, resp) { + appl.redirect('/nonprofits/' + app.nonprofit_id + '/dashboard') + }) +}) diff --git a/app/javascript/legacy/events/show/tour.js b/app/javascript/legacy/events/show/tour.js new file mode 100644 index 00000000..e510e598 --- /dev/null +++ b/app/javascript/legacy/events/show/tour.js @@ -0,0 +1,35 @@ +// License: LGPL-3.0-or-later +require('../../common/vendor/bootstrap-tour-standalone') + +var tour_event = new Tour({ + steps: [ + { + orphan: true, + title: 'Welcome to your new event!', + content: "Hit 'Next' to find out how you can edit and add content to your event before sharing it." + }, + { + element: '.tour-admin', + placement: 'bottom', + title: 'Manage your event', + content: "You can manage your event by clicking on these buttons at the top of the page." + }, + { + element: '.froala-box', + placement: 'right', + title: 'Event details & story', + content: "You can add and format text and image content for your event by typing in this box. Adding images, video or even custom code can help enliven your event description. Click the icons at the top for formatting." + }, + { + orphan: true, + title: 'You’re on your way!', + content: "Once you've added content and ticket levels, and can start sharing your event." + } + ] +}) + + if($.cookie('tour_event') === String(app.nonprofit_id)) { + $.removeCookie('tour_event', {path: '/'}) + tour_event.restart() +} + diff --git a/app/javascript/legacy/events/stats/page.js b/app/javascript/legacy/events/stats/page.js new file mode 100644 index 00000000..f9ca66d6 --- /dev/null +++ b/app/javascript/legacy/events/stats/page.js @@ -0,0 +1,99 @@ +// License: LGPL-3.0-or-later +// npm +const R = require('ramda') +const flyd = require('flyd') +const h = require('snabbdom/h') +const snabbdom = require('snabbdom') +const render = require('flimflam-render') +const filter = require('flyd/module/filter') +const flatMap = require('flyd/module/flatmap') +const every = require('flyd/module/every') + +const format = require('../../common/format') +const request = require('../../common/request') + +const eventsPath = `/nonprofits/${app.nonprofit_id}/events/${app.event_id}` + +const makeStatsSquare = vnode => { + const elm = vnode.elm + const height = elm.offsetHeight + const width = elm.offsetWidth + height > width + ? elm.style.width = height + 'px' + : elm.style.height = width + 'px' +} + +const get = path => R.compose( + flyd.map(x => x.body) + , filter(x => x.status === 200) + )(request({method: 'get', path}).load) + +// makes an ajax call on page load and then every minute +const getEveryMinute = path => flyd.merge( + flyd.stream({}) +, flatMap(time => get(path), every(60 * 1000))) + +const init = () => { + return { + metrics$: getEveryMinute(`${eventsPath}/metrics`) + , activities$: getEveryMinute(`${eventsPath}/activities`) + } +} + +const activity = a => + h('p.stats-activity' + , `${a.supporter_name} got ${a.quantity} ticket${a.quantity > 1 ? 's' : ''}`) + +const statInner = (content, isCircle) => { + const data = { + hook: {postpatch: makeStatsSquare} + , class: {'stat-inner--circular': isCircle} + } + return h('section.stat-inner' + , app.nonprofit.brand_color + ? R.merge(data, {style: {background: app.nonprofit.brand_color}}) + : data + , content) +} + +const view = state => +console.log('metrics', state.metrics$()) || + h('div', [ + h('section.stat-outer', [ + statInner([ + h('div.stat-text', [ + h('h3.stat-title', 'Raised') + , h('h3.stat-number', ['$', format.centsToDollars(state.metrics$().total_paid || 0)]) + ]) + ]) + ]) + , h('section.stat-outer', [ + statInner([ + h('div.stat-text', [ + h('h3.stat-title', 'Attendees') + , h('h3.stat-number', state.metrics$().total_attendees || '0') + ]) + ], true) + ]) + , !app.hide_activity_feed && state.activities$().length + ? h('div.stats-activities', R.map(activity, R.take(3, state.activities$()))) + : '' + , h('div.stats-backgroundScrim', '') + , app.event_background_image + ? h('div.stats-backgroundImage' + , {style: {'background-image': `url('${app.event_background_image}')`}}) + : '' + ]) + +const patch = snabbdom.init([ + require('snabbdom/modules/class') +, require('snabbdom/modules/props') +, require('snabbdom/modules/style') +, require('snabbdom/modules/eventlisteners') +, require('snabbdom/modules/attributes') +]) + +const container = document.querySelector('#container') + +render({patch, container, view, state: init()}) + diff --git a/app/javascript/legacy/gift_options/admin.js b/app/javascript/legacy/gift_options/admin.js new file mode 100644 index 00000000..30ea8c4a --- /dev/null +++ b/app/javascript/legacy/gift_options/admin.js @@ -0,0 +1,101 @@ +// License: LGPL-3.0-or-later +require('../common/restful_resource') +const reorder = require('../components/drag-to-reorder') +const format = require('../common/format') +const R = require('ramda') + +const url = `/nonprofits/${app.nonprofit_id}/campaigns/${app.campaign_id}/campaign_gift_options/update_order` + +reorder(url, 'js-reorderGifts', appl.ajax_gift_options.index) + +appl.def('ajax_gift_options', { + + update: function(form_obj, node) { + if(checkForAmount(form_obj)){ + return + } + var id = appl.gift_options.current.id + appl.ajax.update('gift_options', id, form_obj, node).then(function(resp) { + node.parentNode.reset() + appl.def('loading', false) + appl.ajax_gift_options.index() + appl.notify('Gift option updated successfully') + appl.open_modal('manageGiftOptionsModal') + }) + }, + + create: function(form_obj, node) { + if(checkForAmount(form_obj)){ + return + } + appl.ajax.create('gift_options', form_obj, node).then(function(resp) { + node.parentNode.reset() + appl.def('loading', false) + appl.open_modal('manageGiftOptionsModal') + appl.notify('Gift option created successfully') + appl.ajax_gift_options.index() + }) + }, + + del: function(id, node) { + var task = appl.ajax.del('gift_options', id, node) + task.then(function(resp) { + appl.open_modal('manageGiftOptionsModal') + appl.notify('Gift option removed successfully') + appl.ajax_gift_options.index() + }) + task.catch(function(resp){ + appl.open_modal('manageGiftOptionsModal') + appl.notify('This gift option has already been used. It can\'t be removed') + appl.ajax_gift_options.index() + }) + }, + +// Update or create a gift option depending on which mode we're in + save: function(form_obj, node) { + // the server expects both amount_one_time and amount_recurring to have + // a number value and this function passes in '0' as a fallback in the + // case that either input is left blank by the user + const toCents = x => format.dollarsToCents(x || '0') + + var data = R.evolve({ + amount_one_time: toCents + , amount_recurring: toCents + }, form_obj) + if(appl.gift_options.is_updating) { + appl.ajax_gift_options.update(data, node) + } else { + appl.ajax_gift_options.create(data, node) + } + }, +}) + + +function checkForAmount(form_obj) { + if(!form_obj.amount_one_time && !form_obj.amount_recurring) { + appl.notify('Please enter at least one amount') + return true + } else { + return false + } +} + +appl.def('gift_options', { + resource_name: 'campaign_gift_options', + path_prefix: '/nonprofits/' + app.nonprofit_id + '/campaigns/' + app.campaign_id + '/', + + open_edit: function(gift_option) { + appl.def('gift_options', {current: gift_option, is_updating: true}) + .def('gift_option_action', 'Edit') + + appl.open_modal('giftOptionFormModal') + }, + + open_new: function() { + appl.def('gift_options', {current: undefined, is_updating: false}) + .def('gift_option_action', 'New') + document.querySelector("#giftOptionFormModal form").reset() + appl.open_modal('giftOptionFormModal') + } +}) + diff --git a/app/javascript/legacy/gift_options/index.js b/app/javascript/legacy/gift_options/index.js new file mode 100644 index 00000000..3d45bfe0 --- /dev/null +++ b/app/javascript/legacy/gift_options/index.js @@ -0,0 +1,33 @@ +// License: LGPL-3.0-or-later +require('../common/restful_resource') + +appl.def('gift_options', { + resource_name: 'campaign_gift_options', + path_prefix: '/nonprofits/' + app.nonprofit_id + '/campaigns/' + app.campaign_id + '/', +}) + +appl.def('ajax_gift_options.index', function() { + appl.ajax.index('gift_options').then(function(resp) { + var data = resp.body.data + appl.def('gift_options.data', supplementData(data)) + checkForQuantity(data) + }) +}) + +function supplementData(data) { + return data.map(function(x) { + if(x.quantity) { + var remaining = x.quantity - x.total_gifts + x.remaining = remaining > 0 ? remaining : 0 + } + return x + }) +} + +function checkForQuantity(data) { + data.forEach(function(x){ + if(x.quantity) { + appl.def('gift_options.has_any_quantities', true) + } + }) +} diff --git a/app/javascript/legacy/nonprofits/btn/page.js b/app/javascript/legacy/nonprofits/btn/page.js new file mode 100644 index 00000000..9a3505ac --- /dev/null +++ b/app/javascript/legacy/nonprofits/btn/page.js @@ -0,0 +1,19 @@ +// License: LGPL-3.0-or-later + var Font = require('../../common/brand-fonts'), + utils = require('../../common/utilities'), + $brandedButton = $('.branded-donate-button') + + if(utils.get_param('fixed')){ + $brandedButton.addClass('is-fixed') + $('.centered').css('padding-top', '5px') + } + + var $logoBlue = '#42B3DF', + brandColor = app.nonprofit.brand_color || $logoBlue, + brandFont = Font[app.nonprofit.brand_font] || Font.bitter + + $brandedButton.css({ + 'background-color': brandColor, + 'font-family': brandFont + } + ) diff --git a/app/javascript/legacy/nonprofits/button/amounts.js b/app/javascript/legacy/nonprofits/button/amounts.js new file mode 100644 index 00000000..58bc2c0d --- /dev/null +++ b/app/javascript/legacy/nonprofits/button/amounts.js @@ -0,0 +1,60 @@ +// License: LGPL-3.0-or-later +var flyd = require("flyd") +var h = require("virtual-dom/h") + +var footer = require('./footer') +var radioAndLabelWrapper = require('../../components/radio-and-label-wrapper') + +var namePrefix = 'settings.amounts.' + +var nameStream = flyd.stream() + +module.exports = {root: root, stream: nameStream} + +function root(state) { + return [ + h('header.step-header', [h('h4.step-title', 'Amounts')]), + body(state) + ] +} + +function body(state){ + return h('div.step-inner', [ + menu(), + singleInput(state), + multipleInputs(state), + footer.root('Next', 'type') + ]) +} + +function menu() { + return h('section',[ + radioAndLabelWrapper('radio-multiple-amounts', namePrefix + 'name', {'checked': 'checked', 'value': 'multiple'}, + ["I want donors to be able to select from ", h('strong', 'multiple'), " amounts."], nameStream), + radioAndLabelWrapper('radio-single-amount', namePrefix + 'name', {'value': 'single'}, + ["I want a ", h('strong', 'single, preset'), " amount."], nameStream), + ]) +} + +function input(value, key) { + return h('span.prepend--dollar', + h('input.input--200', {name: namePrefix + key, value: value, onchange: nameStream}) + ) +} + +function displayIf(state, matcher) { + return state.settings.amounts.name === matcher ? 'block' : 'none' +} + +function singleInput(state) { + return h('div.u-marginTop--15', {style: {display: displayIf(state, 'single')}}, input(state.settings.amounts.single, 'single')) +} + +function multipleInputs(state) { + var multiples = state.settings.amounts.multiples + var inputs = [] + for (var key in multiples) { + inputs.push(input(multiples[key], 'multiples.' + key)) + } + return h('section.layout--three.u-marginTop--15', {style: {display: displayIf(state, 'multiple')}}, inputs) +} diff --git a/app/javascript/legacy/nonprofits/button/appearance.js b/app/javascript/legacy/nonprofits/button/appearance.js new file mode 100644 index 00000000..6a8742b5 --- /dev/null +++ b/app/javascript/legacy/nonprofits/button/appearance.js @@ -0,0 +1,94 @@ +// License: LGPL-3.0-or-later +var flyd = require("flyd") +var h = require("virtual-dom/h") + +var footer = require('./footer') +var radioAndLabelWrapper = require('../../components/radio-and-label-wrapper') + +var appearanceStream = flyd.stream() + +module.exports = { + root: root, + stream: appearanceStream +} + +function root(state) { + return [ + h('header.step-header', [ + h('h4.step-title', 'Appearance'), + h('p', 'How would you like to accept donations?') + ]), + h('div.step-inner', [ + table(state), + customText(state), + footer.root('Next', 'designations') + ]) + ] +} + +function table(state) { + return h('table', [ + h('tr', [defaultButton(), fixedButton()]), + h('tr', [embeddedButton(), imageButton(state)]) + ]) +} + +function contentWrapper(title, content) { + return [title, h('div.u-paddingTop--15', content)] +} + +var color = app.nonprofit.brand_color ? app.nonprofit.brand_color : '#42B3DF' +var font = app.nonprofit.brand_font ? app.nonprofit.brand_font : 'inherit' +var buttonStyles = {background: color, 'font-family': font} + +var namePrefix = 'settings.appearance.' + +function defaultButton(){ + var title = 'Default button' + var content = [ h('p.branded-donate-button', {style: buttonStyles}, 'Donate'), + brandedButtonMessage()] + function brandedButtonMessage(){ + if(app.nonprofit.brand_color){return} + return h('p.u-paddingTop--15', + h('small', "To customize the color and font of your button, \ + head over to your settings page and click on 'branding'") + ) + } + return h('td', [radioAndLabelWrapper('radio-default', namePrefix + 'name', {'value': 'default', 'checked': 'checked'}, + contentWrapper(title, content), appearanceStream)]) +} + +function fixedButton(){ + var title = 'Fixed position button' + var content = [h('p.branded-donate-button.is-fixed', {style: buttonStyles}, 'Donate')] + return h('td', [radioAndLabelWrapper('radio-fixed', namePrefix + 'name', {'value': 'fixed'}, + contentWrapper(title, content), appearanceStream)]) +} + +function embeddedButton(){ + var title = 'Embed directly on page' + var content = [ h('img', {src: app.asset_path + "/graphics/mini-amount-step.png", title: title})] + return h('td', [radioAndLabelWrapper('radio-embedded', namePrefix + 'name', {'value': 'embedded'}, + contentWrapper(title, content), appearanceStream)]) +} + +function imageButton(state){ + var title = 'Custom image' + var defaultImg = app.asset_path + "/graphics/donate-elephant.png" + var imgUrl = state.settings.appearance.customImg ? state.settings.appearance.customImg : defaultImg + var content = [ h('img', {src: imgUrl, title: title}), + h('input', {type: 'text', name: namePrefix + 'customImg', placeholder: 'Add your image URL here', onkeyup: appearanceStream})] + return h('td', [radioAndLabelWrapper('radio-custom-image', namePrefix + 'name', {'value': 'custom image'}, + contentWrapper(title, content), appearanceStream)]) +} + +function customText(state) { + var text = state.settings.appearance.customText ? state.settings.appearance.customText : 'Donate' + var title = 'Custom text' + var content = [ + h('a.customText-text', text), + h('input', {type: 'text', name: namePrefix + 'customText', placeholder: 'Type here to change text', onkeyup: appearanceStream}) + ] + return h('section.customText-wrapper', [radioAndLabelWrapper('radio-custom-text', namePrefix + 'name', {'value': 'custom text'}, + contentWrapper(title, content), appearanceStream)]) +} diff --git a/app/javascript/legacy/nonprofits/button/designations.js b/app/javascript/legacy/nonprofits/button/designations.js new file mode 100644 index 00000000..cc569d63 --- /dev/null +++ b/app/javascript/legacy/nonprofits/button/designations.js @@ -0,0 +1,86 @@ +// License: LGPL-3.0-or-later +var flyd = require("flyd") +var h = require("virtual-dom/h") + +var footer = require('./footer') +var radioAndLabelWrapper = require('../../components/radio-and-label-wrapper') + +var nameStream = flyd.stream() +var countStream = flyd.stream() +var inputStream = flyd.stream() + + +flyd.map(function(keyup){ + keyup.target.value = keyup.target.value.replace(/[&"_*`'~]/g, "") +}, inputStream) + + +var namePrefix = 'settings.designations.' + +module.exports = { + root: root, + streams: { + name: flyd.merge(nameStream, inputStream), + count: countStream + } +} + +function root(state) { + return [ + h('header.step-header', h('h4.step-title', 'Designations')), + h('div.step-inner', + [ + body(state), + footer.root('Next', 'amounts') + ]) + ] +} + +function body(state){ + var desigs = state.settings.designations + return [menu(), + input(desigs), + inputs(desigs)] +} + +function menu(){ + return h('aside',[ + radioAndLabelWrapper('radio-no-designations', namePrefix + 'name', {'checked': 'checked', 'value': ''}, + ["I want ", h('strong', 'no'), " designation."], nameStream), + radioAndLabelWrapper('radio-single-designations', namePrefix + 'name', {'value': 'single'}, + ["I want a ", h('strong', 'single, preset'), " designation."], nameStream), + radioAndLabelWrapper('radio-multiple-designations', namePrefix + 'name', {'value': 'multiple'}, + ["I want donors to be able to select from ", h('strong', 'multiple'), " designations (up to 20)."], nameStream), + ]) +} + +function input(desigs){ + return h('input.u-marginTop--15.input--400', + {placeholder: 'Designation name', attributes: {'maxlength': 50}, name: namePrefix + 'single', style: {display: desigs.name === 'single' ? 'block' : 'none'}, + onchange: inputStream + } + ) +} + +function inputs(desigs){ + var prompt = [h('p.pastelBox--green.u-padding--10.u-marginY--10', 'If you would like to add a custom prompt to your donors, \ + please enter it below. Example: "Which radio show would you like to donate to?". The default prompt is "Please select a designation".'), + h('input.u-marginTop--10.input--400', + {placeholder: 'Prompt to donors', attributes: {'maxlength': 50}, name: namePrefix + 'prompt', onkeyup: inputStream}) + ] + var inputs = [] + for(var i = 0; i < desigs.count; i++) { + inputs.push(h('li', h('input.input--400', {attributes: {'maxlength': 50}, placeholder: 'Designation name', name: namePrefix + 'multiples.' + i, onchange: inputStream}))) + } + return h('div', {style: {display: desigs.name === 'multiple' ? 'block' : 'none'}}, [ + prompt, + h('p.pastelBox--blue.u-padding--10.u-marginY--10', 'Enter your designations below.'), + h('ol', [ + inputs, + h('a.button--tiny.edit', {onclick: countStream, attributes: isDisabled(desigs.count)}, [h('i.fa.fa-plus'), ' Add another designation']), + ]) + ]) +} + +function isDisabled(count){ if(count >= 20){return {'disabled' : ''}}} + diff --git a/app/javascript/legacy/nonprofits/button/footer.js b/app/javascript/legacy/nonprofits/button/footer.js new file mode 100644 index 00000000..0bb10d76 --- /dev/null +++ b/app/javascript/legacy/nonprofits/button/footer.js @@ -0,0 +1,11 @@ +// License: LGPL-3.0-or-later +var flyd = require("flyd") +var h = require("virtual-dom/h") + +var footerStream = flyd.stream() + +function root(text, next) { + return h('footer.step-footer', h('button.button', {data: {next: next}, onclick: footerStream}, text)) +} + +module.exports = {root: root, stream: footerStream} diff --git a/app/javascript/legacy/nonprofits/button/hide-dedication.js b/app/javascript/legacy/nonprofits/button/hide-dedication.js new file mode 100644 index 00000000..9541618c --- /dev/null +++ b/app/javascript/legacy/nonprofits/button/hide-dedication.js @@ -0,0 +1,31 @@ +// License: LGPL-3.0-or-later +var flyd = require("flyd") +var h = require("virtual-dom/h") + +var footer = require('./footer') + +var hideStream = flyd.stream() + +var name = 'hideDedication' + +module.exports = {root: root, stream: hideStream} + +function root(state) { + return [ + h('header.step-header', [h('h4.step-title', 'Hide dedication (optional)')]), + h('div.step-inner', [ + body(), + footer.root('Next', 'thankYou') + ]) + ] +} + +function body() { + var message = "If you don't want to give your donors the option to set a dedication, click the checkbox below." + + return [h('p.u-marginBottom--20', message), + h('input.u-marginTop--10', + {id: name + '-checkbox', type: 'checkbox', name: 'settings.' + name, onchange: hideStream}), + h('label.u-bold', {attributes: {for: name + '-checkbox'}}, 'Hide dedication') + ] +} diff --git a/app/javascript/legacy/nonprofits/button/page.js b/app/javascript/legacy/nonprofits/button/page.js new file mode 100644 index 00000000..4839cb41 --- /dev/null +++ b/app/javascript/legacy/nonprofits/button/page.js @@ -0,0 +1,196 @@ +// License: LGPL-3.0-or-later +var view = require("vvvview") +var flyd = require("flyd") +flyd.scanmerge = require("flyd/module/scanmerge") +var h = require("virtual-dom/h") + +var setStateFromValue = require('../../components/set-state-from-value') + +var appearance = require('./appearance') +var designations = require('./designations') +var amounts = require('./amounts') +var type = require('./type') +var hideDedication = require('./hide-dedication') +var thankYou = require('./thank-you') +var preview = require('./preview') + +var $footer = require('./footer').stream + +var state = { + page: window.location.hash.replace('#', '') + ? window.location.hash.replace('#', '') + : 'appearance', + settings: { + appearance: { + name: 'default', + customText: 'Donate' + }, + designations: {count: 1, multiples: {}}, + amounts: { + name: 'multiple', + single: 30, + multiples: {0: 10, 1: 20, 2: 30, 3: 70, 4: 100, 5: 200, 6: 1000 } + }, + type: { name: 'both'}, + thankYou: {} + } +} + +function root(state) { + return h('div', [ + menu(state), + pages(state) + ]) +} + +var $page = flyd.stream() + +var $pageClick = flyd.stream() + +flyd.map(function(ev){ + if(ev.target.data.page === 'preview') { + appendScript() + } else { + removeScript() + } +}, $pageClick) + +$page = flyd.merge($page, + flyd.map(function(ev) { + return ev.target.data.page + }, $pageClick)) + +function appendScript(){ + var script = document.createElement('script') + script.id = 'commitchange-donation-script' + script.setAttribute('data-npo-id', app.nonprofit_id) + script.setAttribute('src', app.host_with_port + '/js/donate-button.v2.js') + document.body.appendChild(script) +} + +function removeScript(){ + if(document.getElementById('commitchange-donation-script')){ + document.getElementById('commitchange-donation-script').remove() + } + removeButtonContent() +} + +function removeButtonContent(){ + var donateButton = document.querySelector('.commitchange-donate') + while(donateButton.lastChild){ + donateButton.removeChild(donateButton.lastChild) + } +} + +function appendButtonCode(){ + document.getElementById('choose-role-modal').classList.add('inView') + document.body.classList.add('is-showingModal') + var buttonWrapper = document.getElementById('js-donateButtonWrapper').cloneNode(true) + while(buttonWrapper.querySelector('iframe')) { + buttonWrapper.querySelector('iframe').remove() + } + while(buttonWrapper.querySelector('div')){ + buttonWrapper.querySelector('div').remove() + } + var code = buttonWrapper.innerHTML.replace(/"/g, "'") + document.getElementById('js-donateButtonAnchor').value = code + document.querySelector('#send-code-modal input[name="code"]').value = code +} + +function menu(state){ + var menuItems = [ + {name: 'appearance', text: 'Appearance'}, + {name: 'designations', text: 'Designations'}, + {name: 'amounts', text: 'Preset amounts'}, + {name: 'type', text: 'Preset recurring or one-time'}, + {name: 'hideDedication', text: 'Hide dedication' }, + {name: 'thankYou', text: 'Thank-you page'}, + {name: 'preview', text: 'Live preview'}] + + var lis =[] + var button = h('div.u-paddingX-10', + h('a.button--large.orange.u-width--full', {onclick: appendButtonCode}, 'Finish')) + + menuItems.map(function(item) { + var liClass = state.page === item.name ? '.active' : '' + lis.push(h('li' + liClass, {data: {page: item.name}, onclick: $pageClick}, item.text)) + }) + return h('aside.stepsMenu', [h('ul', lis), button]) +} + + + +function pageWrapper(state, pageName, content){ + return h('section.step.' + pageName, { + style: {display: state.page === pageName ? 'block' : 'none'} + }, content) +} + +function pages(state){ + return [ + pageWrapper(state, 'appearance', appearance.root(state)), + pageWrapper(state, 'designations', designations.root(state)), + pageWrapper(state, 'amounts', amounts.root(state)), + pageWrapper(state, 'type', type.root(state)), + pageWrapper(state, 'hideDedication', hideDedication.root(state)), + pageWrapper(state, 'thankYou', thankYou.root(state)), + pageWrapper(state, 'preview', preview.root(state)) + ] +} + +var donateFormBuilder = view(root, document.getElementById('js-donateFormBuilder'), state) + +var nameStreams = [appearance.stream, designations.streams.name, amounts.stream, type.stream, hideDedication.stream, thankYou.stream] + .map(function(stream) { return [stream, setStateFromValue]}) + +window.state = state + +var scanPairs = [ + [$page, setPage], + [$footer, advancePage], + [designations.streams.count, addDesignation] +].concat(nameStreams) + +var $state = flyd.immediate(flyd.scanmerge(scanPairs, state)) + +// rerenders the view based on state changes +// takes the view and state stream +flyd.map(donateFormBuilder, $state) + +function setPage(state, pageName){ + state.page = window.location.hash = pageName + return state +} + +function addDesignation(state, ev) { + if(state.settings.designations.count < 20) { + state.settings.designations.count++ + } + return state +} + +function advancePage(state, ev) { + state.page = ev.target.data.next + return state +} + + +// // Send email to webmaster +$('#send-code-modal form').on('submit', function(e) { + var self = this + e.preventDefault() + var data = $(this).serializeObject() + $(this).find('button').loading('Sending...') + $.post('/nonprofits/' + app.nonprofit_id + '/button/send_code.json', data) + .done(function() { + notification('Email sent!') + appl.close_modal() + }) + .complete(function() { + $(self).find('button').disableLoading() + }) + .fail(function(d) { + notification('Error: ' + utils.print_error(d)) + }) +}) + diff --git a/app/javascript/legacy/nonprofits/button/preview.js b/app/javascript/legacy/nonprofits/button/preview.js new file mode 100644 index 00000000..620c611d --- /dev/null +++ b/app/javascript/legacy/nonprofits/button/preview.js @@ -0,0 +1,159 @@ +// License: LGPL-3.0-or-later +var flyd = require("flyd") +var h = require("virtual-dom/h") + +module.exports = {root: root} + +function root(state) { + return [ + h('header.step-header', h('h4.step-title', 'Preview')), + h('div.step-inner', [ + body(state.settings) + ]) + ] +} + +function body(settings){ + if(settings.designations.name === 'multiple'){ + settings.designations.multiples = objToArray(settings.designations.multiples) + } + if(settings.amounts.name === 'multiple') { + settings.amounts.multiples = objToArray(settings.amounts.multiples) + } + return [ + h('p.strong.u-centered', 'Below is a live preview of your donate form'), + donateButton(settings), + table(settings) + ] +} + +function table(settings) { + var table = h('table.table--plaid',[ + h('tr', [h('td', 'Appearance'), appearanceTd(settings.appearance)]), + singleOrMultipleRow(settings.designations, 'Designation'), + singleOrMultipleRow(settings.amounts, 'Amount'), + h('tr', [h('td', 'Recurring or one-time'), h('td', settings.type.name)]), + h('tr', [h('td', 'Hide dedication'), h('td', ifAny(settings.hideDedication ? 'true' : h('span.u-color--grey', 'false')))]), + h('tr', [h('td', 'Thank-you page url'), h('td', ifAny(settings.thankYou.url))]), + ]) + return table +} + +function appearanceTd(data) { + if(data.name === 'custom image') { + return h('td', [data.name, h('p.u-color--grey', data.customImg)]) + } + if(data.name === 'custom text') { + return h('td', [data.name, h('p.u-color--grey', data.customText)]) + } + return h('td', data.name) +} + +function singleOrMultipleRow(obj, text) { + if(obj.name === 'single'){ + return h('tr', [h('td', text), h('td', obj.single+='')]) + } + if(obj.name === 'multiple'){ + return h('tr', [h('td', text + 's'), h('td', arrayToList(obj.multiples))]) + } + return h('tr', [h('td', text), h('td', h('span.u-color--grey', 'none'))]) +} + +function donateButton(settings) { + return h('div.u-centered.u-margin--20', {id: 'js-donateButtonWrapper'}, + h('a.commitchange-donate', {attributes: buttonAttributes(settings)}, + [buttonContent(settings.appearance)] + ) + ) +} + +function buttonAttributes(settings) { + var appearance = settings.appearance.name + var attrs = {} + if(appearance === 'custom image' || appearance === 'custom text') { + attrs['data-custom'] = '' + } + if (appearance === 'fixed') { + attrs['data-fixed'] = '' + } + if (appearance === 'embedded'){ + attrs['data-embedded'] = '' + } + if (settings.designations.name === 'single' && settings.designations.single) { + attrs['data-designation'] = settings.designations.single + } + if (settings.designations.name === 'multiple' && settings.designations.multiples.length) { + attrs['data-multiple-designations'] = arrayToStringWithSeparator(settings.designations.multiples, '_') + } + if (settings.designations.name === 'multiple' && settings.designations.prompt) { + attrs['data-designations-prompt'] = settings.designations.prompt + } + if (settings.amounts.name === 'single' && settings.amounts.single) { + attrs['data-amount'] = settings.amounts.single + } + if (settings.amounts.name === 'multiple' && settings.amounts.multiples.length) { + attrs['data-amounts'] = arrayToStringWithSeparator(settings.amounts.multiples, ',') + } + if (settings.thankYou.url) { + attrs['data-redirect'] = settings.thankYou.url + } + if (settings.type.name === 'one time') { + attrs['data-type'] = 'one-time' + } + if (settings.type.name === 'recurring') { + attrs['data-type'] = 'recurring' + } + if (settings.hideDedication) { + attrs['data-hide-dedication'] = '' + } + return attrs +} + + +function buttonContent(data) { + if (data.name === 'custom image') { + return h('img', {src: data.customImg}) + } + if (data.name === 'custom text') { + return h('span', data.customText) + } +} + +// todo: add to helpers or make global once we move away from view-script + +function arrayToStringWithSeparator(array, separator) { + return array.reduce(function(prev, current){ + return prev + separator + current + }) +} + +function camelCase(string) { + return string.split(" ").reduce(function(prev, current){ + return prev + current.charAt(0).toUpperCase() + current.slice(1) + }) +} + +function ifAny(data) { + if(data) { + return data + } + return h('span.u-color--grey', 'none') +} + +function objToArray(obj) { + var array = [] + for(var key in obj) { + if(obj[key]) { array.push(obj[key])} + } + return array +} + +function arrayToList(array , cssClass) { + var cssClass = cssClass ? cssClass : '.' + 'hasBullets--grey' + var lis = [] + array.map(function(item){ + item+='' + if(item && item.length) {lis.push(h('li', item))} + }) + return h('ul' + cssClass, lis) +} diff --git a/app/javascript/legacy/nonprofits/button/thank-you.js b/app/javascript/legacy/nonprofits/button/thank-you.js new file mode 100644 index 00000000..59d6c3b5 --- /dev/null +++ b/app/javascript/legacy/nonprofits/button/thank-you.js @@ -0,0 +1,29 @@ +// License: LGPL-3.0-or-later +var flyd = require("flyd") +var h = require("virtual-dom/h") + +var footer = require('./footer') + +var namePrefix = 'settings.thankYou.' + +var urlStream = flyd.stream() + +module.exports = {root: root, stream: urlStream} + +function root(state) { + return [ + h('header.step-header', h('h4.step-title', 'Thank-you page (optional)')), + h('div.step-inner', [ + body(), + footer.root('Next', 'preview') + ]) + ] +} + +function body() { + var message = "You can provide a custom URL to your own thank-you page. Your donors will be directed to this page when they complete the donation. Be sure to include the 'http://' or 'https://' part of your url." + + return [h('p', message), + h('input.u-marginTop--10', {type: 'url', placeholder: 'Type your thank-you page URL here', name: namePrefix + 'url', onchange: urlStream}) + ] +} diff --git a/app/javascript/legacy/nonprofits/button/type.js b/app/javascript/legacy/nonprofits/button/type.js new file mode 100644 index 00000000..fe734e3c --- /dev/null +++ b/app/javascript/legacy/nonprofits/button/type.js @@ -0,0 +1,41 @@ +// License: LGPL-3.0-or-later +var flyd = require("flyd") +var h = require("virtual-dom/h") +var footer = require('./footer') +var radioAndLabelWrapper = require('../../components/radio-and-label-wrapper') + +var namePrefix = 'settings.type.' + +var nameStream = flyd.stream() + +module.exports = {root: root, stream: nameStream} + +function root() { + return [ + h('header.step-header', [h('h4.step-title', 'Recurring or One-Time')]), + body() + ] +} + +function body(){ + return h('div.step-inner', [ + menu(), + footer.root('Next', 'hideDedication') + ]) +} + +function menu() { + var recurringImg = h('img', {src: app.asset_path + "/graphics/recurring.svg"}) + var oneTimeImg = h('img', {src: app.asset_path + "/graphics/one-time.svg"}) + var message = "We highly recommend that you accept recurring donations whenever possible. They are a great source of recurring revenue!" + + return h('section',[ + h('p', message), + radioAndLabelWrapper('radio-type-both', namePrefix + 'name', {'checked': 'checked', 'value': 'both'}, + ["Recurring ", h('strong', 'and'), " one time.", recurringImg, oneTimeImg], nameStream), + radioAndLabelWrapper('radio-type-oneTime', namePrefix + 'name', {'value': 'one time'}, + [h('strong', 'Only '), " one time.", oneTimeImg], nameStream), + radioAndLabelWrapper('radio-type-recurring', namePrefix + 'name', {'value': 'recurring'}, + [h('strong', 'Only '), " recurring.", recurringImg], nameStream), + ]) +} diff --git a/app/javascript/legacy/nonprofits/cards/edit/index.es6 b/app/javascript/legacy/nonprofits/cards/edit/index.es6 new file mode 100644 index 00000000..7403cf75 --- /dev/null +++ b/app/javascript/legacy/nonprofits/cards/edit/index.es6 @@ -0,0 +1,61 @@ +// License: LGPL-3.0-or-later +const snabbdom = require('snabbdom') +const h = require('snabbdom/h') +const flyd = require('flyd') +const render = require('ff-core/render') +const notification = require('ff-core/notification') + +const format = require('../../../common/format') +const cardForm = require('../../../components/card-form.es6') + +function init() { + var state = { + card: pageLoadData.card + , plan: pageLoadData.plan + , subscription: pageLoadData.subscription + , daysLeft: pageLoadData.daysLeft + } + + state.cardForm = cardForm.init({ + name: app.profile.name + , zip_code: app.nonprofit.zip_code + , payload: { card: {holder_type: 'Nonprofit', holder_id: app.nonprofit_id, stripe_customer_id: pageLoadData.card.stripe_customer_id}} + , path: `/nonprofits/${app.nonprofit_id}/card` + }) + + // Notify on card update success + var message$ = flyd.map(()=>'Successfully updated! Now redirecting...', state.cardForm.saved$) + state.notification = notification.init({message$}) + + // For now, just redirect to settings page after updating card + flyd.map(resp => { window.location.href = '/settings' }, state.cardForm.saved$) + + return state +} + + +const view = state => + h('div.u-centered.u-maxWidth--600.u-margin--auto.u-marginTop--50.u-padding--15.js-view-confirm', [ + h('h4', `Payment Method for ${app.nonprofit.name}`) + , state.card.name ? h('p', `Current card: ${state.card.name}`) : '' + , h('p.u-strong', `Tier: ${state.plan.name} ($${format.centsToDollars(state.plan.amount)} ${state.plan.interval})`) + , h('hr') + , h('h5', 'Update Your Card:') + , h('div', [ cardForm.view(state.cardForm) ]) + + , h('br'), h('br'), h('br'), h('br') // lol + + , notification.view(state.notification) + ]) + + +// -- Render +var container = document.querySelector('#js-main') +const patch = snabbdom.init([ + require('snabbdom/modules/eventlisteners') +, require('snabbdom/modules/class') +, require('snabbdom/modules/props') +, require('snabbdom/modules/style') +]) +render({state: init(), view, container, patch}) + diff --git a/app/javascript/legacy/nonprofits/cards/edit/page.js b/app/javascript/legacy/nonprofits/cards/edit/page.js new file mode 100644 index 00000000..f9b577f7 --- /dev/null +++ b/app/javascript/legacy/nonprofits/cards/edit/page.js @@ -0,0 +1,4 @@ +// License: LGPL-3.0-or-later + +require('./index.es6') + diff --git a/app/javascript/legacy/nonprofits/dashboard/page.js b/app/javascript/legacy/nonprofits/dashboard/page.js new file mode 100644 index 00000000..3a7d388d --- /dev/null +++ b/app/javascript/legacy/nonprofits/dashboard/page.js @@ -0,0 +1,83 @@ +// License: LGPL-3.0-or-later +require('../../campaigns/new/wizard') +require('../../events/new/wizard') +require('./tour') +appl.verify_identity = require('../payouts/index/verify_identity') +appl.create_bank_account = require('../../bank_accounts/create.es6') +var client = require('../../common/client') +var create_info_card = require('../../supporters/info-card.es6') +require('../payments_chart') + +appl.def('loading', true) + +client.get('/nonprofits/' + app.nonprofit_id + '/dashboard_metrics') + .end(function(err, resp) { + appl.def('loading', false) + appl.def('metrics.data', resp.body.data) + }) + +var map = require('../../components/maps/cc_map') +var npo_coords = require('../../components/maps/npo_coordinates')() +map.init('all-npo-supporters', {center: npo_coords}, {npo_id: app.nonprofit.id}) + +var todos = require('../../components/todos') +appl.def('todos_action', '/dashboard_todos') + +todos(function(data, url) { + appl.def('todos.items', [ + {text: "Collect first donation", done: data['has_donation'], link: url + '/button/basic' }, + {text: "Create first campaign", done: data['has_campaign'], modal_id: 'newCampaign', confirmed: true }, + {text: "Connect bank account", done: data['has_bank'], modal_id: 'newBankModal' }, + {text: "Create first event", done: data['has_event'], modal_id: 'newEvent', confirmed: true }, + {text: "Add a custom Thank You note for receipts", done: data['has_thank_you'], link: "/settings?p=receipts&s=settings-pane" }, + {text: "Import supporter data", done: data['has_imported'], link: url + '/supporters' }, + {text: "Brand fundraising tools", done: data['has_branding'], link: '/settings?p=branding&s=settings-pane' } + ]) + if(data['has_bank']){ + appl.todos.items.push({text: "Verify your identity", done: data['is_verified'], modal_id:'identityVerificationModal', confirmed: true}) + appl.def('todos.items', appl.todos.items) + } +}) + +// the only ff component so far on this page is events listings +const R = require('ramda') +const h = require('snabbdom/h') +const flyd = require('flyd') +const render = require('ff-core/render') + +const request = require('../../common/request') +const listing = require('../../events/listing-item') + +const init = _ => { + var path = `/nonprofits/${app.nonprofit_id}/events/listings?active=t` + return {resp$: request({path, method: 'get'}).load} +} + +const view = state => { + const mixin = content => h('section', content) + if(!state.resp$()) + return mixin([h('p.u-padding--15.u-centered', 'Loading...')]) + if(!state.resp$().body.length) + return mixin([h('p.u-padding--15.u-centered', `None currently`)]) + return mixin(R.map(listing, state.resp$().body)) +} + +var container = document.querySelector('#js-eventsListing') + +const patch = require('snabbdom').init([ + require('snabbdom/modules/class') +, require('snabbdom/modules/props') +, require('snabbdom/modules/eventlisteners') +, require('snabbdom/modules/style') +, require('snabbdom/modules/attributes') +]) + +render({ patch, container , view, state: init() }) + + +// End-of-year report modal initialization and rendering +// XXX we should FLIMFLAMify the whole dashboard and make it a single tree with one render statement +const reportModal = require('../reports/modal') +const reportContainer = document.createElement('div') +document.body.appendChild(reportContainer) +render({state: reportModal.init(), view: reportModal.view, container: reportContainer, patch}) diff --git a/app/javascript/legacy/nonprofits/dashboard/tour.js b/app/javascript/legacy/nonprofits/dashboard/tour.js new file mode 100644 index 00000000..65e2f5b2 --- /dev/null +++ b/app/javascript/legacy/nonprofits/dashboard/tour.js @@ -0,0 +1,68 @@ +// License: LGPL-3.0-or-later +require('../../common/vendor/bootstrap-tour-standalone') + +var $nav = $('.sideNav') +var $text = $('.sideNav-text') + +function showNav(){ + $nav.css('width', '240px') + $text.css({ + '-webkit-opacity' : '1', + '-moz-opacity': '1', + '-ms-opacity': '1', + 'opacity': '1' + }) +} + +function hideNav(){ + $nav.removeAttr('style') + $text.removeAttr('style') +} + +var dashboard_tour = new Tour({ + backdrop: false, + steps: [ + { + orphan: true, + title: 'Welcome to CommitChange!', + content: "This dashboard will give you a detailed overview of all of your fundraising activities. As you begin to raise money through donations, contributions and ticket sales, this dashboard will show more helpful information." + }, + { + element: '.tour-graph', + placement: 'bottom', + title: 'Graph', + content: "This graph will chart your donation history. You can change the time span from the top of the graph." + }, + { + element: '.tour-metrics', + placement: 'left', + title: 'Overview metrics', + content: "These metrics will help show you the big picture of your fundraising." + }, + { + element: '.tour-listings', + placement: 'left', + title: 'Recent metrics', + content: "These metrics will help give you a day-to-day picture of your fundraising. You can also create a new campaign or event by simply clicking on one of the orange buttons." + }, + { + backdrop: false, + orphan: true, + title: 'Navigation', + content: "To find other parts of our site, such as payments history, settings and your profile page, use the sidebar on the left.", + onHide: hideNav, + onShow: showNav, + }, + { + orphan: true, + title: "You're all set!", + content: "Check your inbox for an email confirmation link. We will verify your status as a nonprofit within 5-7 days. Contact support@commitchange.com if you have any questions. We're glad to have you on board!" + } + ] +}) + +if($.cookie('tour_dashboard') === String(app.nonprofit_id)) { + $.removeCookie('tour_dashboard', {path: '/'}) + dashboard_tour.init() + dashboard_tour.restart() +} diff --git a/app/javascript/legacy/nonprofits/donate/amount-step.js b/app/javascript/legacy/nonprofits/donate/amount-step.js new file mode 100644 index 00000000..26d0d2d9 --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/amount-step.js @@ -0,0 +1,199 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const R = require('ramda') +const flyd = require('flyd') +const format = require('../../common/format') +flyd.scanMerge = require('flyd/module/scanmerge') + +function init(donationDefaults, params$) { + var state = { + params$: params$ + , evolveDonation$: flyd.stream() // Stream of objects that can be used to R.evolve the initial donation object + , buttonAmountSelected$: flyd.stream(true) // Whether the button or input is selected + , currentStep$: flyd.stream() + } + + // A stream of objects that an be used to modify the existing donation by using R.evolve + donationDefaults = R.merge(donationDefaults, { + amount: format.dollarsToCents(state.params$().single_amount || 0) + , designation: state.params$().designation + , recurring: state.params$().type === 'recurring' + , weekly: (typeof state.params$().weekly !== 'undefined') + }) + // Apply R.evolve using every value on the evolveDonation$ stream, starting with the defaults + state.donation$ = flyd.scanMerge([ + [state.params$ || flyd.stream(), setDonationFromParams] + , [state.evolveDonation$, R.flip(R.evolve)] + ], donationDefaults) + + return state +} + +const setDonationFromParams = (donation, params) => { + if(params.single_amount) { + donation.amount = format.dollarsToCents(params.single_amount) + } + else + donation.amount = undefined + if(params.designation) + donation.designation = params.designation + else + donation.designation = undefined + if (params.type === 'recurring') + donation.recurring = true + else + donation.recurring = undefined + return donation +} + +function view(state) { + const isRecurring = state.donation$().recurring + return h('div.wizard-step.amount-step', [ + chooseDesignation(state) + , recurringCheckbox(isRecurring, state) + , recurringMessage(isRecurring, state) + , amountFields(state) + , showSingleAmount(isRecurring, state) + ]) +} + + +// Dropdown to choose among custom designations +function chooseDesignation(state) { + if(!state.params$().multiple_designations) return '' + var defaultDesigs = [ + state.params$().designations_prompt || I18n.t('nonprofits.donate.amount.designation.choose') + , I18n.t('nonprofits.donate.amount.designation.most_needed') + ] + return h('section.u-paddingX--5', { + class: {'u-hide': !state.params$().multiple_designations} + }, [ + h('select.donate-designationDropdown.select.u-marginBottom--10', { + on: { change: ev => state.evolveDonation$({designation: R.always(ev.currentTarget.value)}) } + }, R.concat( + R.map( + d => h('option', {props: {value: ''}}, d) + , defaultDesigs + ) + , R.map( + d => h('option', {props: {value: d}}, d) + , state.params$().multiple_designations + ) + ) + ) + ]) +} + +// Checkbox to make the donation monthly recurring +function recurringCheckbox(isRecurring, state) { + if(state.params$().type === 'recurring' || state.params$().type === 'one-time') return '' + return h('section.donate-recurringCheckbox.u-paddingX--5 u-marginBottom--10', [ + h('div.u-padding--8.u-background--grey.u-centered', { + class: {highlight: isRecurring} + }, [ + h('input.u-margin--0.donationWizard-amount-input', { + props: {type: 'checkbox', selected: isRecurring, id: 'checkbox-recurring'} + , on: {change: ev => state.evolveDonation$({recurring: t => !t})} + }) + , h('label', {props: {htmlFor: 'checkbox-recurring'}}, composeTranslation( + I18n.t('nonprofits.donate.amount.sustaining') + , I18n.t('nonprofits.donate.amount.sustaining_bold') + ) + ) + ]) + ]) +} + +// If recurring, an extra message to reinforce that it is in fact charged every month +function recurringMessage(isRecurring, state) { + if(!isRecurring) return '' + var label=I18n.t('nonprofits.donate.amount.sustaining_selected') + var bolded=I18n.t('nonprofits.donate.amount.sustaining_selected_bold'); + if (state.donation$().weekly) { + label = label.replace(I18n.t('nonprofits.donate.amount.monthly'),I18n.t('nonprofits.donate.amount.weekly')); + bolded=I18n.t('nonprofits.donate.amount.weekly'); + } + return h('section.donate-recurringMessage.group', [ + h('p.u-paddingX--5.u-centered', { + class: {'u-hide': !isRecurring} + }, [ + state.params$().single_amount ? '' : h('small.info', composeTranslation(label,bolded)) + ]) + ]) +} + +function prependCurrencyClassname() { + if (app.currency_symbol === '$') { + return 'prepend--dollar' + } else if (app.currency_symbol === '€') { + return 'prepend--euro' + } +} + +function composeTranslation(full, bold) { + const texts = full.split(bold) + if(texts.length > 1) { + return [texts[0], h('strong', bold), texts[1]] + } else { + return full + } +} + +// All the buttons and the custom input for the amounts to select +function amountFields(state) { + if(state.params$().single_amount) return '' + return h('div.u-inline.fieldsetLayout--three--evenPadding', [ + h('span', + R.map( + amt => h('fieldset', [ + h('button.button.u-width--full.white.amount', { + class: {'is-selected': state.buttonAmountSelected$() && state.donation$().amount === amt*100} + , on: {click: ev => { + state.evolveDonation$({amount: R.always(format.dollarsToCents(amt))}) + state.buttonAmountSelected$(true) + state.currentStep$(1) // immediately advance steps when selecting an amount button + } } + }, [ + h('span.dollar', app.currency_symbol) + , String(amt) + ]) + ]) + , state.params$().custom_amounts || [] ) + ) + , h('fieldset.' + prependCurrencyClassname(), [ + h('input.amount.other', { + props: {name: 'amount', step: 'any', type: 'number', min: 1, placeholder: I18n.t('nonprofits.donate.amount.custom')} + , class: {'is-selected': !state.buttonAmountSelected$()} + , on: { + focus: ev => state.buttonAmountSelected$(false) + , change: ev => state.evolveDonation$({amount: R.always(format.dollarsToCents(ev.currentTarget.value))}) + } + }) + ]) + , h('fieldset', [ + h('button.button.u-width--full.btn-next', { + props: {type: 'submit', disabled: !state.donation$().amount || state.donation$().amount <= 0} + , on: {click: [state.currentStep$, 1]} + }, I18n.t('nonprofits.donate.amount.next')) + ]) + ]) +} + +// If the params have a single amount, show a large message saying how much it is +function showSingleAmount(isRecurring, state) { + if(!state.params$().single_amount) return '' + var gift = state.params$().gift_option || {} + if(state.params$().gift_option_name) gift.name = state.params$().gift_option_name + var desig = state.params$().designation + return h('section.u-centered', [ + h('p.singleAmount-message', [ + h('strong', app.currency_symbol + format.centsToDollars(format.dollarsToCents(state.params$().single_amount))) + , h('span.u-padding--0', { class: {'u-hide': !isRecurring} }, ' monthly') + , h('span', {class: {'u-hide': !state.params$().designation && !gift.id}}, [ ' for ' + (desig || gift.name) ]) + ]) + , h('button.button.u-marginBottom--20', {on: {click: [state.currentStep$, 1]}}, I18n.t('nonprofits.donate.amount.next')) + ]) +} + +module.exports = {view, init} + diff --git a/app/javascript/legacy/nonprofits/donate/dedication-form.js b/app/javascript/legacy/nonprofits/donate/dedication-form.js new file mode 100644 index 00000000..9771b513 --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/dedication-form.js @@ -0,0 +1,97 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const R = require('ramda') +const flyd = require('flyd') +const uuid = require('uuid') + +// A contact info form for a donor to add a dedication in honor/memory of somebody + + +function view(state) { + var radioId1 = uuid.v1() // need unique ids for the checkbox id and label for attrs + var radioId2 = uuid.v1() + var data = state.dedicationData$() || {} + return h('form.dedication-form', { + on: {submit: ev => {ev.preventDefault(); state.submitDedication$(ev.currentTarget)}} + }, [ + h('p.u-centered.u-strong.u-marginBottom--10', I18n.t('nonprofits.donate.dedication.info')) + , h('fieldset.u-marginBottom--0.col-6', [ + h('input', {props: { + name: 'dedication_type' + , type: 'radio' + , id: radioId1 + , value: 'honor' + , checked: !data.dedication_type || data.dedication_type === 'honor' + }}) + , h('label', {props: {htmlFor: radioId1}}, I18n.t('nonprofits.donate.dedication.in_honor_label')) + ]) + , h('fieldset.u-marginBottom--0', [ + h('input', {props: { + name: 'dedication_type' + , type: 'radio' + , value: 'memory' + , id: radioId2 + , checked: data.dedication_type === 'memory' + }}) + , h('label', {props: {htmlFor: radioId2}}, I18n.t('nonprofits.donate.dedication.in_memory_label')) + ]) + , h('fieldset.u-marginBottom--0.col-6', [ + h('input', {props: { + name: 'first_name' + , placeholder: I18n.t('nonprofits.donate.dedication.first_name') + , title: 'First name' + , type: 'text' + , value: data.first_name + }}) + ]) + , h('fieldset.u-marginBottom--0', [ + h('input', {props: { + name: 'last_name' + , placeholder: I18n.t('nonprofits.donate.dedication.last_name') + , title: 'Last name' + , type: 'text' + , value: data.last_name + }}) + ]) + , h('fieldset.u-marginBottom--0.col-6', [ + h('input', {props: { + name: 'email' + , placeholder: I18n.t('nonprofits.donate.dedication.email') + , title: 'Email' + , type: 'text' + , value: data.email + }}) + ]) + , h('fieldset.u-marginBottom--0', [ + h('input', {props: { + name: 'phone' + , placeholder: I18n.t('nonprofits.donate.dedication.phone') + , title: 'Phone' + , type: 'text' + , value: data.phone + }}) + ]) + , h('fieldset.u-marginBottom--0', [ + h('input', {props: { + name: 'address' + , placeholder: I18n.t('nonprofits.donate.dedication.full_address') + , title: 'Address' + , type: 'text' + , value: data.address + }}) + ]) + , h('fieldset', [ + h('textarea', {props: { + name: 'dedication_note' + , placeholder: I18n.t('nonprofits.donate.dedication.note') + , title: 'Note' + , value: data.dedication_note + }}) + ]) + , h('div.u-centered', [ + h('button.button', I18n.t('nonprofits.donate.dedication.save')) + ]) + ]) +} + +module.exports = {view} diff --git a/app/javascript/legacy/nonprofits/donate/followup-step.js b/app/javascript/legacy/nonprofits/donate/followup-step.js new file mode 100644 index 00000000..f1bd69d5 --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/followup-step.js @@ -0,0 +1,39 @@ +// License: LGPL-3.0-or-later + +const h = require('snabbdom/h') +function view(state) { + //if (window.parent) {window.parent.postMessage('commitchange:followup', '*');}; + + const supp = state.infoStep.savedSupp$() + return h('div.u-padding--10.u-centered', [ + h('h6.u-marginTop--15', I18n.t('nonprofits.donate.followup.success')) + , supp ? h('p', `${I18n.t('nonprofits.donate.followup.receipt_info')} ${supp.email}`) : '' + , h('hr') + , h('p', state.thankyou_msg || `${app.nonprofit.name} ${I18n.t('nonprofits.donate.followup.message')}`) + , h('div.u-inlineBlock.u-marginRight--10', [ + h('a.button--small.facebook.u-width--full.share-button', { + props: { + target: '_blank' + , href: 'https://www.facebook.com/dialog/feed?app_id='+app.facebook_app_id +"&display=popup&caption=" + encodeURIComponent(app.campaign.name || app.nonprofit.name) + "&link="+window.location.href + } + }, [h('i.fa.fa-facebook-square'), ` ${I18n.t('nonprofits.donate.followup.share.facebook')}`] ) + ]) + , h('div.u-inlineBlock.u-marginLeft--10.u-marginBottom--20', [ + h('a.button--small.twitter.u-width--full', { + props: { + target: '_blank' + , href: "https://twitter.com/intent/tweet?url="+window.location.href+"&via=CommitChange&text=Join me in supporting:" + (app.campaign.name || app.nonprofit.name) + } + }, [h('i.fa.fa-twitter-square'), ` ${I18n.t('nonprofits.donate.followup.share.twitter')}`] ) + ]) + // Show the 'finish' button only if we're in an offsite embedded modal + , state.params$().offsite + ? h('div', [ + h('button.button.finish', {on: {click: state.clickFinish$}}, I18n.t('nonprofits.donate.followup.finish')) + ]) + : '' + ]) +} + + +module.exports = {view} diff --git a/app/javascript/legacy/nonprofits/donate/get-params.js b/app/javascript/legacy/nonprofits/donate/get-params.js new file mode 100644 index 00000000..f5920195 --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/get-params.js @@ -0,0 +1,23 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') + +const splitParam = str => + R.split(/[_;,]/, str) + +module.exports = params => { + const defaultAmts = '10,25,50,100,250,500,1000' + // Set defaults + const merge = R.merge({ + custom_amounts: '' + }) + // Preprocess data + const evolve = R.evolve({ + multiple_designations: splitParam + , custom_amounts: amts => R.compose(R.map(Number), splitParam)(amts || defaultAmts) + , custom_fields: fields => R.map(f => { + const [name, label] = R.map(R.trim, R.split(':', f)) + return {name, label: label ? label : name} + }, R.split(',', fields)) + }) + return R.compose(evolve, merge)(params) +} diff --git a/app/javascript/legacy/nonprofits/donate/info-step.js b/app/javascript/legacy/nonprofits/donate/info-step.js new file mode 100644 index 00000000..f7c78189 --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/info-step.js @@ -0,0 +1,187 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const R = require('ramda') +const flyd = require('flyd') +const uuid = require('uuid') +const supporterFields = require('../../components/supporter-fields') +const button = require('ff-core/button') +const dedicationForm = require('./dedication-form') +const serialize = require('form-serialize') +const request = require('../../common/request') +const format = require('../../common/format') + +const sepaTab = 'sepa' +const cardTab = 'credit_card' + +function init(donation$, parentState) { +//console.log(donation$().val); + var state = { + donation$: donation$ + , submitSupporter$: flyd.stream() + , submitDedication$: flyd.stream() + , params$: parentState.params$ + , currentStep$: flyd.stream() + , selectedPayment$: parentState.selectedPayment$ + } + + + // Save supporter for dedication logic + state.dedicationData$ = flyd.map(form => serialize(form, {hash: true}), state.submitDedication$) + const dedicationSuppData$ = flyd.map( + data => R.merge( + R.pick(['phone', 'email', 'address'], data) + , {name: `${data.first_name||''} ${data.last_name||''}`} + ) + , state.dedicationData$ + ) + state.showDedicationForm$ = flyd.map(()=> false, state.submitDedication$) + + // Save donor supporter record + state.supporterFields = supporterFields.init({required: {email: true}}, parentState.params$) + state.savedSupp$ = flyd.flatMap(postSupporter , flyd.map(formatFormData, state.submitSupporter$)) + state.savedDedicatee$ = flyd.map( + supporter => ({supporter, note: state.dedicationData$().dedication_note, type: state.dedicationData$().dedication_type}) + , flyd.flatMap(postSupporter, dedicationSuppData$) + ) + const changedDedication$ = flyd.merge(state.dedicationData$, state.savedDedicatee$) + state.supporter$ = flyd.merge(flyd.stream({}), state.savedSupp$) + + return state +} + +const formatFormData = form => { + const data = serialize(form, {hash: true}) + return R.evolve({customFields: R.toPairs}, data) +} + +const postSupporter = supporter => + flyd.map( + resp => resp.body + , request({ + method: 'post' + , path: `/nonprofits/${app.nonprofit_id}/supporters` + , send: R.merge(supporter, {locale: I18n.locale}) + }).load + ) + + +const customFields = fields => { + if(!fields) return '' + const input = field => h('input', { + props: { + name: `customFields[${field.name}]` + , placeholder: field.label + } + }) + return h('div', R.map(input, fields)) +} + +function recurringMessage(state){ +//function recurringMessage(isRecurring, state) { + var isRecurring=state.donation$().recurring; + var amountLabel = isRecurring ? ` ${I18n.t('nonprofits.donate.payment.monthly_recurring')}` : ` ${I18n.t('nonprofits.donate.payment.one_time')}` + var weekly= ""; + if (state.donation$().weekly) { + amountLabel = amountLabel.replace(I18n.t('nonprofits.donate.amount.monthly'),I18n.t('nonprofits.donate.amount.weekly')) + "*"; + weekly= h('div.u-centered.notice',[h("small",I18n.t('nonprofits.donate.amount.weekly_notice',{amount:(format.weeklyToMonthly(state.donation$().amount)/100.0),currency:app.currency_symbol}))]); + + } + return h('div', [ + h('p.u-fontSize--18 u.marginBottom--0.u-centered.amount', [ + h('span', app.currency_symbol + format.centsToDollars(state.donation$().amount)) + , h('strong', amountLabel) + ]) + , weekly] + ) +} + +function view(state) { + + var form = h('form', { + on: { + submit: ev => {ev.preventDefault(); state.currentStep$(2); state.submitSupporter$(ev.currentTarget)} + } + }, [ + recurringMessage(state) + , supporterFields.view(state.supporterFields) + , customFields(state.params$().custom_fields) + , dedicationLink(state) + , app.nonprofit.no_anon ? '' : anonField(state) + , h('fieldset.u-inlineBlock.u-marginTop--10', paymentMethodButtons(["card", "sepa"], state)) + ]) + return h('div.wizard-step.info-step.u-padding--10', [ + form + , h('div', { + style: {background: '#f8f8f8', position: 'absolute', 'top': '0', left: '3px', height: '100%', width: '99%'} + , class: {'u-hide': !state.showDedicationForm$(), opacity: 0, transition: 'opacity 1s', delay: {opacity: 1}} + }, [dedicationForm.view(state)] ) + ]) +} + +function paymentMethodButtons(paymentMethods, state){ + return h('section.group'), [ + paymentButton({error$: state.errors$, buttonText: I18n.t('nonprofits.donate.payment.tabs.sepa')}, sepaTab, state) + , paymentButton({error$: state.errors$, buttonText: I18n.t('nonprofits.donate.payment.tabs.card')}, cardTab, state) + ] +} + +function paymentButton(options, label, state){ + options.error$ = options.error$ || flyd.stream() + options.loading$ = options.loading$ || flyd.stream() + + let btnclass={ 'ff-button--loading': options.loading$() }; + btnclass[label]=true; + + return h('div.ff-buttonWrapper.u-floatL.u-marginBottom--10', { + class: { 'ff-buttonWrapper--hasError': options.error$() } + }, [ + h('p.ff-button-error', {style: {display: options.error$() ? 'block' : 'none'}} , options.error$()) + , h('button.ff-button', { + props: { type: 'submit', disabled: options.loading$() } + , on: { click: e => state.selectedPayment$(label) } + , class: btnclass + }, [ + options.loading$() ? (options.loadingText || " Saving...") : (options.buttonText || I18n.t('nonprofits.donate.payment.card.submit')) + ]) + ]) +} + +function anonField(state) { + state.anon_id = state.anon_id || uuid.v1() // we need a unique id in case there are multiple supporter forms on the page -- the label 'for' attribute needs to be unique + return h('div.u-marginTop--10.u-centered', [ + h('input', { + props: { + type: 'checkbox' + , name: 'anonymous' + , checked: state.anonymous + , id: `anon-checkbox-${state.anon_id}` + } + }) + , h('label', { + props: { + type: 'checkbox' + , htmlFor: `anon-checkbox-${state.anon_id}` + , id: 'anonLabel' + } + }, [ + h('small', I18n.t('nonprofits.donate.info.anonymous_checkbox')) + ]) + ]) +} + +const dedicationLink = state => { + if(state.params$().hide_dedication) return '' + return h('label.u-centered.u-marginTop--10', [ + h('small', [ + h('a', { + on: {click: [state.showDedicationForm$, true]} + }, state.dedicationData$() && state.dedicationData$().first_name + ? [h('i.fa.fa-check'), I18n.t('nonprofits.donate.info.dedication_saved') + `${state.dedicationData$().first_name || ''} ${state.dedicationData$().last_name || ''}`] + : [I18n.t('nonprofits.donate.info.dedication_link')] + ) + ]) + ]) +} + + +module.exports = {view, init} diff --git a/app/javascript/legacy/nonprofits/donate/page.js b/app/javascript/legacy/nonprofits/donate/page.js new file mode 100644 index 00000000..09fb6c87 --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/page.js @@ -0,0 +1,85 @@ +// License: LGPL-3.0-or-later +require('parsleyjs') + +const render = require('ff-core/render') +const donate = require('./wizard') +const snabbdom = require('snabbdom') +const flyd = require('flyd') +const R = require('ramda') +const url = require('url') + +const request = require('../../common/request') + +const patch = snabbdom.init([ + require('snabbdom/modules/eventlisteners') +, require('snabbdom/modules/class') +, require('snabbdom/modules/props') +, require('snabbdom/modules/style') +]) + +const params = url.parse(location.href, true).query +const params$ = flyd.stream(params) +app.params$ = params$ +if(params.campaign_id && params.gift_option_id) { + setGiftOptionParams(params.campaign_id, params.gift_option_id) +} + +// Listen to postMessages to change params +window.addEventListener('message', receiveMessage, false) +function receiveMessage(event) { + var ps + try { ps = JSON.parse(event.data) } + catch(e) {} + if(ps && ps.sender === 'commitchange') { + if (ps.command) { + var event = new CustomEvent('message:'+ps.command,{data:ps}); + container.dispatchEvent(event); + } + if(ps.command === 'setDonationParams') { + params$(ps) + // Fetch the gift option data if they passed a gift option id + if(ps.campaign_id && ps.gift_option_id) { + setGiftOptionParams(ps.campaign_id, ps.gift_option_id) + } + } + } +} + +// Given a gift option id, make a request to get its full data and set the other params accordingly +function setGiftOptionParams(campaign_id, gift_id) { + flyd.map( + resp => { + if(resp.status !== 200) return + var gift_option = resp.body.data + var params = params$() + params.gift_option = gift_option + params.single_amount = (gift_option.amount_one_time || gift_option.amount_recurring) / 100 + if(params.type === 'recurring' && gift_option.amount_recurring) { + params.single_amount = gift_option.amount_recurring / 100 + } else if(!gift_option.amount_one_time && gift_option.amount_recurring) { + params.type = 'recurring' + } else if(params.type === 'recurring' && !gift_option.amount_recurring) { + params.type = undefined + } + params$(params) + } + , request({ + method: 'get' + , path: `/nonprofits/${ENV.nonprofitID}/campaigns/${campaign_id}/campaign_gift_options/${gift_id}` + }).load + ) +} + +var state = donate.init(params$) +var container = document.querySelector('.js-donateForm') + +$(".donationWizard").trigger("render:pre"); +var event = new CustomEvent('render:pre'); +container.parentNode.dispatchEvent(event); +render({patch, view: donate.view, state, container}) +jQuery(function($){ +$(".donationWizard").trigger("render:post").addClass("displayed-updated"); +}); +// event = new CustomEvent('render:post'); +// container.parentNode.dispatchEvent(event); + diff --git a/app/javascript/legacy/nonprofits/donate/payment-step.js b/app/javascript/legacy/nonprofits/donate/payment-step.js new file mode 100644 index 00000000..524f5f33 --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/payment-step.js @@ -0,0 +1,175 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const R = require('ramda') +const flyd = require('flyd') +flyd.lift = require('flyd/module/lift') +flyd.flatMap = require('flyd/module/flatmap') +const request = require('../../common/request') +const cardForm = require('../../components/card-form.es6') +const sepaForm = require('../../components/sepa-form.es6') +const format = require('../../common/format') +const progressBar = require('../../components/progress-bar') + +const sepaTab = 'sepa' +const cardTab = 'credit_card' + +function init(state) { + const payload$ = flyd.map(supp => ({card: {holder_id: supp.id, holder_type: 'Supporter'}}), state.supporter$) + const supporterID$ = flyd.map(supp => supp.id, state.supporter$) + const card$ = flyd.merge( + flyd.stream({}) + , flyd.map(supp => ({name: supp.name, address_zip: supp.zip_code}), state.supporter$)) + + state.cardForm = cardForm.init({ path: '/cards', card$, payload$ }) + state.sepaForm = sepaForm.init({ supporter: supporterID$ } ) + + // Set the card ID into the donation object when it is saved + const cardToken$ = flyd.map(R.prop('token'), state.cardForm.saved$) + const donationWithCardToken$ = flyd.lift(R.assoc('token'), cardToken$, state.donation$) + + // Set the sepa transfer details ID into the donation object when it is saved + const sepaId$ = flyd.map(R.prop('id'), state.sepaForm.saved$) + const donationWithSepaId$ = flyd.lift(R.assoc('direct_debit_detail_id'), sepaId$, state.donation$) + + state.donationParams$ = flyd.immediate( + flyd.combine((sepaParams, cardParams, activeTab) => { + if(activeTab() == sepaTab) { + return sepaParams() + } else if(activeTab() == cardTab) { + return cardParams() + } + }, [donationWithSepaId$, donationWithCardToken$, state.activePaymentTab$]) + ) + const donationResp$ = flyd.flatMap(postDonation, state.donationParams$) + + state.error$ = flyd.mergeAll([ + flyd.map(R.prop('error'), flyd.filter(resp => resp.error, donationResp$)) + , flyd.map(R.always(undefined), state.cardForm.form.submit$) + , flyd.map(R.always(undefined), state.sepaForm.form.submit$) + , state.cardForm.error$ + , state.sepaForm.error$ + ]) + state.paid$ = flyd.filter(resp => !resp.error, donationResp$) + + // Control progress bar for card payment + state.progress$ = flyd.scanMerge([ + [state.cardForm.form.validSubmit$, R.always({status: I18n.t('nonprofits.donate.payment.loading.checking_card'), percentage: 20})] + , [state.cardForm.saved$, R.always({status: I18n.t('nonprofits.donate.payment.loading.sending_payment'), percentage: 100})] + , [state.cardForm.error$, R.always({hidden: true})] // Hide when an error shows up + , [flyd.filter(R.identity, state.error$), R.always({hidden: true})] // Hide when an error shows up + ], {hidden: true}) + + state.loading$ = flyd.mergeAll([ + flyd.map(R.always(true), state.cardForm.form.validSubmit$) + , flyd.map(R.always(true), state.sepaForm.form.validSubmit$) + , flyd.map(R.always(false), state.paid$) + , flyd.map(R.always(false), state.cardForm.error$) + , flyd.map(R.always(false), state.sepaForm.error$) + , flyd.map(R.always(false), state.error$) + ]) + + // Post the gift option, if necessary + const paramsWithGift$ = flyd.filter(params => params.gift_option_id || params.gift_option && params.gift_option.id, state.params$) + const paidWithGift$ = flyd.lift(R.pair, paramsWithGift$, state.paid$) + flyd.map( + R.apply((params, result) => postGiftOption(params.gift_option_id || params.gift_option.id, result)) + , paidWithGift$ + ) + + // post utm tracking details after donation is saved + flyd.map( + R.apply((utmParams, donationResponse) => postTracking(app.utmParams, donationResp$)) + , state.paid$ + ) + + return state +} + +const postGiftOption = (campaign_gift_option_id, result) => { + return flyd.map(R.prop('body'), request({ + path: '/campaign_gifts' + , method: 'post' + , send: {campaign_gift: {donation_id: result.json + ? result.json.donation.id // for recurring + : result.donation.id // for one-time + , campaign_gift_option_id}} + }).load) +} + +const postTracking = (utmParams, donationResponse) => { + const params = R.merge(utmParams, {donation_id: donationResponse().donation.id}) + + if(utmParams.utm_source || utmParams.utm_medium || utmParams.utm_content || utmParams.utm_campaign) { + return flyd.map(R.prop('body'), request({ + path: `/nonprofits/${app.nonprofit_id}/tracking` + , method: 'post' + , send: params + }).load) + } +} + +var posting = false // hack switch to prevent any kind of charge double post +// Post either a recurring or one-time donation +const postDonation = (donation) => { + if(posting) return flyd.stream() + else posting = true + var prefix = `/nonprofits/${app.nonprofit_id}/` + var postfix = donation.recurring ? 'recurring_donations' : 'donations' + + if(donation.weekly) { + donation.amount = Math.round(4.3 * donation.amount); + } + delete donation.weekly; // needs to be removed to be processed + + if(donation.recurring) donation = {recurring_donation: donation} + return flyd.map(R.prop('body'), request({ + path: prefix + postfix + , method: 'post' + , send: donation + }).load) +} + +const paymentTabs = (state) => { + if(state.activePaymentTab$() == sepaTab) { + return payWithSepaTab(state) + } else if(state.activePaymentTab$() == cardTab) { + return payWithCardTab(state) + } +} + +const payWithSepaTab = state => { + return h('div.u-marginBottom--10', [ + sepaForm.view(state.sepaForm) + ]) +} + +const payWithCardTab = state => { + return h('div.u-marginBottom--10', [ + cardForm.view(R.merge(state.cardForm, {error$: state.error$, hideButton: state.loading$()})) + , progressBar(state.progress$()) + ]) +} + +function view(state) { + var isRecurring = state.donation$().recurring + var dedic = state.dedicationData$() + var amountLabel = isRecurring ? ` ${I18n.t('nonprofits.donate.payment.monthly_recurring')}` : ` ${I18n.t('nonprofits.donate.payment.one_time')}` + var weekly=""; + if (state.donation$().weekly) { + amountLabel = amountLabel.replace(I18n.t('nonprofits.donate.amount.monthly'),I18n.t('nonprofits.donate.amount.weekly')) + "*"; + weekly= h('div.u-centered.notice',[h("small",I18n.t('nonprofits.donate.amount.weekly_notice',{amount:(format.weeklyToMonthly(state.donation$().amount)/100.0),currency:app.currency_symbol}))]); + } + return h('div.wizard-step.payment-step', [ + h('p.u-fontSize--18 u.marginBottom--0.u-centered.amount', [ + h('span', app.currency_symbol + format.centsToDollars(state.donation$().amount)) + , h('strong', amountLabel) + ]) + , weekly + , dedic && (dedic.first_name || dedic.last_name) + ? h('p.u-centered', `${dedic.dedication_type === 'memory' ? I18n.t('nonprofits.donate.dedication.in_memory_label') : I18n.t('nonprofits.donate.dedication.in_honor_label')} ` + `${dedic.first_name || ''} ${dedic.last_name || ''}`) + : '' + , paymentTabs(state) + ]) +} + +module.exports = {view, init} diff --git a/app/javascript/legacy/nonprofits/donate/plugins-available/alwaysAnonymous.js b/app/javascript/legacy/nonprofits/donate/plugins-available/alwaysAnonymous.js new file mode 100644 index 00000000..6a8cf68f --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/plugins-available/alwaysAnonymous.js @@ -0,0 +1,11 @@ +// License: LGPL-3.0-or-later +jQuery(function($){ +$(".donationWizard").on("render:post", function(){ + var cb=document.getElementsByName("anonymous")[0]; + cb || console.log("ERROR: the checkbox anonymous ain't no more"); + cb.checked = true; + cb.parentNode.style.display="none"; + +}); + +}); diff --git a/app/javascript/legacy/nonprofits/donate/plugins-available/default-recurring.js b/app/javascript/legacy/nonprofits/donate/plugins-available/default-recurring.js new file mode 100644 index 00000000..eb2816ae --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/plugins-available/default-recurring.js @@ -0,0 +1,9 @@ +// License: LGPL-3.0-or-later +// This plugin allows to simplify the form (remove phone, address and city fields) if the url query has "minimal" +jQuery(function($){ + $(".donationWizard").on("render:post", function(){ + if (app.params$().default !== "recurring") return; + $("#checkbox-recurring").prop("checked",true); + document.getElementById("checkbox-recurring").dispatchEvent(new Event('change')); + }); +}) diff --git a/app/javascript/legacy/nonprofits/donate/plugins-available/dummy.js b/app/javascript/legacy/nonprofits/donate/plugins-available/dummy.js new file mode 100644 index 00000000..26b548e9 --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/plugins-available/dummy.js @@ -0,0 +1,16 @@ +// License: LGPL-3.0-or-later +(function () { + +var container = document.querySelector('.js-donateForm'); + +container.addEventListener('render:pre', function (e) { + console.log(e); + // e.target matches elem +}, false); + +container.addEventListener('render:post', function (e) { + console.log(e); + // e.target matches elem +}, false); + +})(); diff --git a/app/javascript/legacy/nonprofits/donate/plugins-available/ibanonly.js b/app/javascript/legacy/nonprofits/donate/plugins-available/ibanonly.js new file mode 100644 index 00000000..b1fd3941 --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/plugins-available/ibanonly.js @@ -0,0 +1,5 @@ +// License: LGPL-3.0-or-later +jQuery(function($){ + $("input[name='bic']").closest("fieldset").hide(); + $("input[name='iban']").closest("fieldset").removeClass("col-8").addClass("col-12"); +}); diff --git a/app/javascript/legacy/nonprofits/donate/plugins-available/minamount.js b/app/javascript/legacy/nonprofits/donate/plugins-available/minamount.js new file mode 100644 index 00000000..fed80060 --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/plugins-available/minamount.js @@ -0,0 +1,4 @@ +// License: LGPL-3.0-or-later +jQuery(function($){ + jQuery("input[name='amount']").attr("min",3); +}); diff --git a/app/javascript/legacy/nonprofits/donate/plugins-available/minimalForm.js b/app/javascript/legacy/nonprofits/donate/plugins-available/minimalForm.js new file mode 100644 index 00000000..1899e28b --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/plugins-available/minimalForm.js @@ -0,0 +1,20 @@ +// License: LGPL-3.0-or-later +// This plugin allows to simplify the form (remove phone, address and city fields) if the url query has "minimal" +jQuery(function($){ +$(".donationWizard").on("render:post", function(){ + + if (!app.params$().minimal) return; + document.getElementsByName("phone")[0].style.display="none"; + document.getElementsByName("city")[0].style.display="none"; + document.getElementsByName("address")[0].style.display="none"; + + document.getElementsByName("first_name")[0].parentNode.classList.add('col-right-6'); + document.getElementsByName("first_name")[0].parentNode.classList.remove('col-right-4'); + document.getElementsByName("last_name")[0].parentNode.classList.add('col-right-6'); + document.getElementsByName("last_name")[0].parentNode.classList.remove('col-right-4'); + + document.getElementsByName("country")[0].parentNode.classList.add('col-right-8'); + document.getElementsByName("country")[0].parentNode.classList.remove('col-right-4'); + +}); +}); diff --git a/app/javascript/legacy/nonprofits/donate/plugins-available/piwik.js b/app/javascript/legacy/nonprofits/donate/plugins-available/piwik.js new file mode 100644 index 00000000..cafad8d8 --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/plugins-available/piwik.js @@ -0,0 +1,23 @@ +// License: LGPL-3.0-or-later +var _paq = _paq || []; +/* tracker methods like "setCustomDimension" should be called before "trackPageView" */ +_paq.push(['trackPageView']); +_paq.push(['enableLinkTracking']); +var tracker=null; +(function() { +var u="//s.wemove.eu/"; +_paq.push(['setTrackerUrl', u+'piwik.php']); +_paq.push(['setSiteId', '5']); +var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; +g.onload = function() { + tracker=Piwik.getTracker("https://s.wemove.eu/piwik.php",5); + tracker.trackContentImpressionsWithinNode(document.getElementsByClassName("amount-step")[0]); +//trakcer.trackContentInteractionNode', document.getElementsByClassName("amount-step")[0], 'amount-step']); + tracker.trackContentImpressionsWithinNode(document.getElementsByClassName("info-step")[0]); + tracker.trackContentImpressionsWithinNode(document.getElementsByClassName("payment-step")[0]); + + tracker.trackContentImpressionsWithinNode(document.getElementsByClassName("ff-wizard-followup")[0]) +}; +g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s); +//_paq.push(["setHeartBeatTimer",30]); +})(); diff --git a/app/javascript/legacy/nonprofits/donate/plugins-available/prefill-identity.js b/app/javascript/legacy/nonprofits/donate/plugins-available/prefill-identity.js new file mode 100644 index 00000000..f972b391 --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/plugins-available/prefill-identity.js @@ -0,0 +1,23 @@ +// License: LGPL-3.0-or-later +// This plugin allows to automatically fill the form (name, address..) based on the url params + +jQuery(function($){ +$(".donationWizard").on("render:post", function(){ + ["email","first_name","last_name","city","zip_code","country"].forEach(function(k){ + var v=app.params$()[k]; + if (!v) return; + document.getElementsByName(k)[0].value=v; + }); + + var name =""; + if (app.params$().first_name) + name = app.params$().first_name + " "; + if (app.params$().last_name) + name += app.params$().last_name; + if (name.length > 1) { + document.getElementsByName("name").forEach(function(d){ + d.value=name; + }); + } +}); +}); diff --git a/app/javascript/legacy/nonprofits/donate/plugins-available/prettify.js b/app/javascript/legacy/nonprofits/donate/plugins-available/prettify.js new file mode 100644 index 00000000..2b757272 --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/plugins-available/prettify.js @@ -0,0 +1,9 @@ +// License: LGPL-3.0-or-later +jQuery(function($){ + $(".closeButton").hide(); + if (app.currency_symbol != "€") $("button.sepa").hide(); + $("button.sepa").prepend('S€PA ').addClass("u-marginRight--10"); + $("button.credit_card").prepend(' '); + + $(".ff-wizard-followup a, .ff-wizard-followup button").hide(); // buttons FB and twitter bogus, finish too +}); diff --git a/app/javascript/legacy/nonprofits/donate/plugins-available/select-amount.js b/app/javascript/legacy/nonprofits/donate/plugins-available/select-amount.js new file mode 100644 index 00000000..fdc02804 --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/plugins-available/select-amount.js @@ -0,0 +1,15 @@ +// License: LGPL-3.0-or-later +// This plugin allows to automatically chose an amount if the url query has "amount"=xz +jQuery(function($){ +$(".donationWizard").on("render:post", function(){ + + if (!app.params$().amount) return; + var amount= parseInt(app.params$().amount,10); + if (!amount > 0) return; + // TODO: check if the pre-selected amount is one of the buttons, instead of only putting it in "other" + $(".amount.other").val(amount).addClass("is-selected"); + $('.amount-step button.button').removeClass("is-selected"); + document.getElementsByName("amount")[0].dispatchEvent(new Event('change')); + $(".btn-next").click(); +}); +}); diff --git a/app/javascript/legacy/nonprofits/donate/wizard.js b/app/javascript/legacy/nonprofits/donate/wizard.js new file mode 100644 index 00000000..5993868f --- /dev/null +++ b/app/javascript/legacy/nonprofits/donate/wizard.js @@ -0,0 +1,208 @@ +// License: LGPL-3.0-or-later +const flyd = require('flyd') +const R = require('ramda') +const h = require('snabbdom/h') +const url = require('url') +const render = require('ff-core/render') +const wizard = require('ff-core/wizard') +const scanMerge = require('flyd/module/scanmerge') +flyd.mergeAll = require('flyd/module/mergeall') +flyd.flatMap = require('flyd/module/flatmap') +flyd.zip = require('flyd-zip') + +const getParams = require('./get-params') + +const paymentStep = require('./payment-step') +const amountStep = require('./amount-step') +const infoStep = require('./info-step') +const followupStep = require('./followup-step') + +const request = require('../../common/request') +const format = require('../../common/format') + +const brandedWizard = require('../../components/styles/branded-wizard') +const renderStyles = require('../../components/styles/render-styles') + +renderStyles()(brandedWizard(null)) + +// pass in a stream of configuration parameters +const init = params$ => { + var state = { + error$: flyd.stream() + , loading$: flyd.stream() + , clickLogout$: flyd.stream() + , clickFinish$: flyd.stream() + , params$: flyd.map(getParams, params$) + } + + app.iframeParams = app.iframeParams || "" + app.utmParams = app.utmParams || {} + // maps utmParams from URL params string into object: + // { $utm_param: … } if params from iframe are present + app.iframeParams = app.iframeParams.split("?")[2] ? Object.assign(...app.iframeParams.split("?")[2].split("&").map((param) => param.split("=")).map( + array => ({[array[0]]: array[1]})) + ) : {} + + + app.utmParams = { + utm_campaign: app.utmParams.utm_campaign || app.iframeParams.utm_campaign, + utm_content: app.utmParams.utm_content || app.iframeParams.utm_content, + utm_medium: app.utmParams.utm_medium || app.iframeParams.utm_medium, + utm_source: app.utmParams.utm_source || app.iframeParams.utm_source + } + + app.campaign = app.campaign || {} // so we don't have to hot switch all the calls to app.campaign.name, etc + var donationDefaults = setDonationFromParams({ + nonprofit_id: app.nonprofit_id + , campaign_id: app.campaign.id + , event_id: app.event_id + }, state.params$()) + + state.selectedPayment$ = flyd.stream('sepa') + + state.amountStep = amountStep.init(donationDefaults, state.params$) + state.infoStep = infoStep.init(state.amountStep.donation$, state) + + state.donation$ = scanMerge([ + [state.amountStep.donation$, R.merge] + , [state.infoStep.savedSupp$, (d, supp) => R.assoc('supporter_id', supp.id, d)] + , [state.params$, setDonationFromParams] + , [state.infoStep.savedDedicatee$, setDonationDedication] + ], donationDefaults ) + + state.paymentStep = paymentStep.init({ + supporter$: state.infoStep.savedSupp$ + , donation$: state.donation$ + , dedicationData$: state.infoStep.dedicationData$ + , activePaymentTab$: state.selectedPayment$ + , params$: state.params$ + }) + + const currentStep$ = flyd.mergeAll([ + state.amountStep.currentStep$ + , state.infoStep.currentStep$ + , flyd.map(R.always(0), state.params$) // if the params ever change, jump back to step one + , flyd.stream(0) + ]) + state.wizard = wizard.init({currentStep$, isCompleted$: state.paymentStep.paid$}) + + // Save dedication as a supporter note once the donation is saved + // Requires the donor supporter, the dedicatee supporter, the dedication form data, and the paid donation + const dedicationParams$ = flyd.zip([state.infoStep.savedDedicatee$, state.infoStep.savedSupp$, state.paymentStep.paid$]) + const savedDedication$ = flyd.flatMap(R.apply(postDedication), dedicationParams$) + + // Log people out + flyd.map(ev => {request({method: 'get', path: '/users/sign_out'}); window.location.reload()}, state.clickLogout$) + + // Handle the Finish button from the followup step -- will close modal, redirect, or refresh + flyd.lift( + (ev, params) => { + if(!parent) return + if(params.redirect) parent.postMessage(`commitchange:redirect:${params.redirect}`, '*') + else if(params.mode !== 'embedded'){ + parent.postMessage('commitchange:close', '*'); + } else { + if (window.parent) {window.parent.postMessage('commitchange:close', '*');}; + } + } + , state.clickFinish$, state.params$ ) + + return state +} + +const setDonationFromParams = (don, params) => { + if(!params.single_amount || isNaN(format.dollarsToCents(params.single_amount))) delete params.single_amount + return R.merge({ + amount: params.single_amount ? format.dollarsToCents(params.single_amount) : 0 + , recurring: params.type === 'recurring' + , gift_option_id: params.gift_option_id + , designation: params.designation + }, don) +} + +// Set the text field to save to the server as serialized JSON +const setDonationDedication = (don, dedication) => { + return R.assoc( + 'dedication' + , JSON.stringify({ + supporter_id: dedication.supporter.id + , name: dedication.supporter.name + , contact: {email: dedication.supporter.email, + phone: dedication.supporter.phone, + address: dedication.supporter.address} + , note: dedication.note + , type: dedication.type + }) + , don) +} + + +// Save a dedication to the server by saving a note to the supporter +const postDedication = (dedication, donor, donation) => { + const pathPrefix = `/nonprofits/${ENV.nonprofitID}` + // TODO: translate content + var content = `[${donor.name}](${pathPrefix}/supporters?sid=${donor.id}) made a [donation of $${format.centsToDollars(donation.donation.amount)}](${pathPrefix}/payments?pid=${donation.payment.id}) in ${dedication.type || 'honor'} of this person.` + if(dedication.note) content += ` ${I18n.t('nonprofits.donate.dedication.donor_note')} "${dedication.note}".` + return flyd.map(r => r.body, request({ + method: 'post' + , path: `/nonprofits/${app.nonprofit_id}/supporters/${dedication.supporter.id}/supporter_notes` + , send: {supporter_note: {supporter_id: dedication.supporter.id, user_id: ENV.support_user_id, content}} + }).load) +} + +const view = state => { + return h('div.js-donateForm', { + class: {'is-modal': state.params$().offsite} + }, [ + h('img.closeButton', { + props: {src: '/assets/ui_components/close.svg'} + , on: {click: ev => state.params$().offsite && !state.params$().embedded ? parent.postMessage('commitchange:close', '*') : null} + , class: {'u-hide': !state.params$().offsite || !state.params$().embedded} + }) + , h('div.titleRow', [ + h('img', {props: {src: app.nonprofit.logo.normal.url}}) + , h('div.titleRow-info', [ + h('h2', app.campaign.name || app.nonprofit.name ) + , h('p', [ + state.params$().designation && !state.params$().single_amount + ? headerDesignation(state) + : app.campaign.tagline || app.nonprofit.tagline || '' + ]) + ]) + ]) + , wizardWrapper(state) + , h('footer.donateForm-footer', { + class: {'u-hide': !app.user} + }, [ + h('span', `${I18n.t('nonprofits.donate.signed_in')} `) + , h('strong', String(app.user && app.user.email)) + , h('a.logout-button', {on: {click: state.clickLogout$}}, ` ${I18n.t('nonprofits.donate.log_out')}`) + ]) + ]) +} + +const headerDesignation = state => { + return h('span', [ + h('i.fa.fa-star', {style: {color: app.nonprofit.brand_color || ''}}) + , h('strong', ` ${I18n.t('nonprofits.donate.amount.designation.label')} `) + , String(state.params$().designation) + , state.params$().designation_desc + ? h('span', [h('br'), h('small', state.params$().designation_desc)]) + : '' + ]) +} + +const wizardWrapper = state => { + return h('div.wizard-steps.donation-steps', [ + wizard.view(R.merge(state.wizard, { + steps: [ + {name: I18n.t('nonprofits.donate.amount.label'), body: amountStep.view(state.amountStep)} + , {name: I18n.t('nonprofits.donate.info.label'), body: infoStep.view(state.infoStep)} + , {name: I18n.t('nonprofits.donate.payment.label'), body: paymentStep.view(state.paymentStep)} + ] + , followup: followupStep.view(state) + })) + ]) +} + +module.exports = {view, init} diff --git a/app/javascript/legacy/nonprofits/edit/page.js b/app/javascript/legacy/nonprofits/edit/page.js new file mode 100644 index 00000000..1a69d7dc --- /dev/null +++ b/app/javascript/legacy/nonprofits/edit/page.js @@ -0,0 +1,8 @@ +// License: LGPL-3.0-or-later +require('../../common/image_uploader') +require('../../common/on-change-sanitize-slug') +var url = "/nonprofits/" + app.nonprofit_id + +appl.def('remove_this_image', function() { + appl.remove_background_image(url, 'nonprofit') +}) diff --git a/app/javascript/legacy/nonprofits/payments/index/page.js b/app/javascript/legacy/nonprofits/payments/index/page.js new file mode 100755 index 00000000..9db03097 --- /dev/null +++ b/app/javascript/legacy/nonprofits/payments/index/page.js @@ -0,0 +1,102 @@ +// License: LGPL-3.0-or-later +require('../../../components/date_range_picker') +require('../../../common/panels_layout') +require('./tour') +require('../../../common/restful_resource') +require('../../../refunds/create') +require('../../supporters/get_name') +require('./payment_details') +require('../../../components/tables/filtering/apply_filter')('payments') +require('../../../common/ajax/get_campaign_and_event_names_and_ids')(app.nonprofit_id) +require('../../supporters/index/import') +var format = require('../../../common/format') + +appl.def('format', require('../../../common/format')) + +appl.def('payments.index', function() { + appl.def('loading', true) + appl.ajax.index('payments').then(function(resp) { + appl.def('loading', false) + if(appl.payments.query.page > 1) { + var main_panel = document.querySelector('.mainPanel') + main_panel.scrollTop = main_panel.scrollHeight + } + }) +}) + + +appl.def('payments.clear_search_if_deleted', function(val) { + if(val === '') { + appl.def('payments.query', {search: '', page: 1}) + appl.payments.index() + } +}) + + +appl.def("payments", { + query: {page: 1}, + concat_data: true +}) + +appl.def('filter_count', 0) + +if(window.location.search) + ajax_from_params() +else + appl.payments.index() + + +appl.def('payments.toggle_panel', function(id, el){ + var tr = el.parentNode + + if(tr.hasAttribute('data-selected')) { + appl.close_side_panel() + tr.removeAttribute('data-selected','') + } else { + appl.ajax_payment_details.fetch(id) + $('.mainPanel').find('tr').removeAttr('data-selected') + tr.setAttribute('data-selected','') + var path = window.location.pathname + "?pid=" + id + window.history.pushState({},'payment id', path) + } +}) + + +appl.def('readable_kind', function(kind, el) { + if(kind === "Donation") return "One-Time Donation" + else if(kind === "OffsitePayment") return "Offsite Donation" + else if(kind === "Ticket") return "Ticket Purchase" + else return format.camelToWords(kind) +}) + + +appl.def('kind_icon_class', function(kind) { + if(kind === "Donation") return "fa-heart" + if(kind === "OffsitePayment") return "fa-money" + if(kind === "RecurringDonation") return "fa-refresh" + if(kind === "Ticket") return "fa-ticket" + if(kind === "Refund") return "fa-rotate-left" +}) + +appl.def('formatted_gross_amount', function(amt) { + if(amt < 0) { + return '(' + appl.cents_to_dollars(Math.abs(amt)) + ')' + } else { + return appl.cents_to_dollars(amt) + } +}) + +function ajax_from_params() { + var payment_id = utils.get_param('pid') + var supporter_id = utils.get_param('sid') + appl.is_loading() + if(supporter_id) { + appl.payments.query = {page: appl.payments.query.page, search: supporter_id} + appl.payments.index() + } + if(payment_id) { + appl.payments.index() + appl.ajax_payment_details.fetch(payment_id) + } +} + diff --git a/app/javascript/legacy/nonprofits/payments/index/payment_details.js b/app/javascript/legacy/nonprofits/payments/index/payment_details.js new file mode 100644 index 00000000..5eb5e4d4 --- /dev/null +++ b/app/javascript/legacy/nonprofits/payments/index/payment_details.js @@ -0,0 +1,166 @@ +// License: LGPL-3.0-or-later +var request = require('../../../common/super-agent-promise') +var readable_interval = require('../../recurring_donations/readable_interval') +var format = require('../../../common/format') + +appl.def('ajax_payment_details', { + fetch: function(id) { + appl.def('loading', true) + appl.def('payment_details.data', null) // since appl.def is a merge, we want to instead overwrite all data + appl.ajax.fetch('payment_details', id) + .then(function(resp) { + appl.def('loading', false) + appl.def('payment_details', appl.payment_details) + appl.def('payment_details.data.offsite_payment', appl.payment_details.data.offsite_payment) + appl.open_side_panel() + return appl.payment_details.data.charge && appl.payment_details.data.charge.id + }) + .then(fetch_refunds) + .catch(function(err) { + console.error(err) + appl.not_loading() + }) + } +}) + + +appl.def('payment_details', { + resource_name: 'payments' +}) + +// Utilities and view helper functions for payment details + +appl.def('payment_recurring_don', function(payment) { + return payment && payment.donation && payment.donation.recurring_donation +}) + +appl.def('get_recurring_interval', function(payment) { + var rd = appl.payment_recurring_don(payment) + if(!rd) return '' + return readable_interval(rd.interval, rd.time_unit) +}) + +appl.def('get_recurring_created', function(payment) { + var rd = appl.payment_recurring_don(payment) + if(!rd) return '' + return appl.readable_date(rd.created_at) +}) + +appl.def('payment_has_campaign', function(payment) { + return get_payment_campaign(payment) +}) + +appl.def('payment_campaign_name', function(payment) { + var c = get_payment_campaign(payment) + return c && c.name +}) + +appl.def('payment_campaign_url', function(payment) { + var c = get_payment_campaign(payment) + return c && c.url +}) + +appl.def('payment_has_event', function(payment) { + return payment && payment.event +}) + +appl.def('payment_event_name', function(payment) { + return payment && payment.event && payment.event.name +}) + +appl.def('payment_event_url', function(payment) { + return payment && payment.event && payment.event.url +}) + +// Given a payment, get either the designation, campaign name, or event name +appl.def('get_payment_purchase_object', function(payment) { + if(payment.tickets.length && payment.tickets[0].event) { + return "Event: " + payment.tickets[0].event.name + } else if(payment.donation) { + if(payment.donation.campaign) { + return "Campaign: " + payment.donation.campaign.name + } else { + if(payment.donation.designation) { + return "Designation: " + payment.donation.designation + } else if(payment.donation.dedication) { + return "In honor: " + payment.donation.dedication + } + } + } +}) + +appl.def('start_loading', function(){ + appl.def('loading', true) +}) + +appl.def('update_donation__success', function() { + appl.ajax_payment_details.fetch(appl.payment_details.data.id) + appl.def('loading', false) + // appl.close_modal() + appl.notify('Donation successfully updated!') +}) + +appl.def('delete_offline_donation', function() { + var payment = appl.payment_details.data + request + .del('/nonprofits/' + app.nonprofit_id + '/payments/' + payment.id) + .perform() + .then(function(resp) { + appl.notify("That offsite payment has been successfully deleted.") + appl.close_side_panel() + appl.payments.index() + }) +}) + +function fetch_refunds(charge_id) { + if(!charge_id) return + request.get('/nonprofits/' + app.nonprofit_id + "/charges/" + charge_id + "/refunds") + .perform() + .then(function(resp) { + appl.def('payment_details.refunds', resp.body) + }) +} + +function get_payment_campaign(payment) { + return payment && payment.donation && payment.donation.campaign +} + +appl.def('resend_receipt', function(type) { + var payment = appl.payment_details.data + appl.def('loading', true) + var url = `/nonprofits/${app.nonprofit_id}/payments/${payment.id}/resend_${type}_receipt` + var message = type === 'donor' + ? `Donation receipt emailed to ${app.user.email}` + : `Donation receipt emailed to you` + request.post(url) + .perform() + .then(function(resp) { + appl.def('loading', false) + appl.notify(message) + }) +}) + +// Format the JSON for a serialized dedication, which can have supporter_id, note, and type (honor/memory) +appl.def('format_dedication', function(dedic, node) { + var td = appl.prev_elem(node) + if(!td) return + var inner = '' + if (dedic) { + var json + try { json = JSON.parse(dedic) } catch(e) {} + if(json) { + let supporter_link = (json.supporter_id && json.supporter_id != '') ? + `${json.name}` : + json.name + inner = ` + Donation made in ${json.type || 'honor'} of + ${supporter_link}. + ${json.note ? `
Note: ${json.note}.` : ''} + ` + } else { + // Print plaintext dedication + inner = '' + } + } + td.innerHTML = inner +}) diff --git a/app/javascript/legacy/nonprofits/payments/index/tour.js b/app/javascript/legacy/nonprofits/payments/index/tour.js new file mode 100644 index 00000000..7014dbe5 --- /dev/null +++ b/app/javascript/legacy/nonprofits/payments/index/tour.js @@ -0,0 +1,45 @@ +// License: LGPL-3.0-or-later +require('../../../common/vendor/bootstrap-tour-standalone') + +var transactions_tour = new Tour({ + backdrop: false, + steps: [ + { + orphan: true, + title: 'Welcome to your payments history dashboard!', + content: "This page shows your complete payments history. This includes donations through your website, campaign contributions, tickets for events, and offline checks." + }, + { + element: '.tour-filter', + placement: 'right', + title: 'Filtering & searching', + content: "Filter your payments using this panel. You can also use the search bar at the top to search by donor name or email.", + onHide: appl.close_filter_panel, + onShow: appl.open_filter_panel, + }, + { + element: '.tour-totalPayments', + placement: 'bottom', + title: 'Pending balance', + content: "This is your organization's pending balance. This amount is held temporarily in escrow until it is withdrawn into your organization's bank account." + }, + { + element: '.tour-payouts', + placement: 'bottom', + title: 'Payouts dashboard', + content: "To setup payouts and to see your payout history, you can click on this tab." + }, + { + orphan: true, + title: 'Check back!', + content: "As your organization starts to receive donations and contributions and to sell event tickets, check back here to watch your pending balance increase. Please contact support@commitchange.com if you have questions." + } + ] +}) + +if($.cookie('tour_transactions') === String(app.nonprofit_id)) { + $.removeCookie('tour_transactions', {path: '/'}) + transactions_tour.init() + transactions_tour.restart() +} + diff --git a/app/javascript/legacy/nonprofits/payments_chart.js b/app/javascript/legacy/nonprofits/payments_chart.js new file mode 100644 index 00000000..9a971d03 --- /dev/null +++ b/app/javascript/legacy/nonprofits/payments_chart.js @@ -0,0 +1,111 @@ +// License: LGPL-3.0-or-later +const request = require('../common/client') +const R = require('ramda') +const Chart = require('chart.js') +const Pikaday = require('pikaday') +const moment = require('moment') +const chartOptions = require('../components/chart-options') + +var frontendFormat = 'M/D/YYYY' +var backendFormat = 'YYYY-MM-DD' + +// set the default query to get the last year of payments +// and group them by month +var defaultParams = { + endDate: moment().format(backendFormat) + , startDate: moment().subtract(12, 'months').format(backendFormat) + , timeSpan: 'month' +} + +var pickadayDefaults = {format: frontendFormat, setDefaultDate: true} + +appl.def('updateChartParams', function(formObj) { + updateChart({ + endDate: moment(formObj.endDate).format(backendFormat) + , startDate: moment(formObj.startDate).format(backendFormat) + , timeSpan: formObj.timeSpan + }) +}) + +// start date Pickaday +new Pikaday(R.merge({ + field: document.getElementById('js-paymentsChart-startDate') + , maxDate: moment().subtract(1, 'week').toDate() + , defaultDate: moment().subtract(1, 'years').toDate() +}, pickadayDefaults)) + +// end date Pickaday +new Pikaday(R.merge({ + field: document.getElementById('js-paymentsChart-endDate') + , maxDate: moment().toDate() + , defaultDate: moment().toDate() +}, pickadayDefaults)) + +var ctx = document.getElementById('js-paymentsChart').getContext('2d') + +var chart = new Chart(ctx, { + type: 'bar' +, options: chartOptions.dollars +, data: {labels: [], datasets: []} +}) + +var url = `/nonprofits/${app.nonprofit_id}/payment_history` + +function updateChart(params) { + appl.def('loading_chart', true) + request.get(url) + .query(params) + .end(function(err, resp) { + chart.data.labels = formatLabels(R.pluck('time_span', resp.body), params.timeSpan) + chart.data.datasets = formatDatasets(resp.body) + chart.update() + appl.def('loading_chart', false) + }) +} + +function formatLabels(dates, type) { + switch (type) { + case "year": + return R.map(st => moment(st).format('YYYY'), dates) + case "month": + return R.map(st => moment(st).format('MMM YYYY'), dates) + case "week": + return R.map(st => + `${moment(st).format('M/D/YY')} - ${moment(st).add(7, 'days').format('M/D/YY')}` + , dates) + default: + return R.map(st => moment(st).format(frontendFormat), dates) + } +} + +const formatDatasets = (data) => [ + dataset('One time' + , 'onetime_cents' + , '66, 179, 223' + , data) + , dataset('Recurring' + , 'recurring_cents' + , '240, 205, 108' + , data) + , dataset('Tickets' + , 'tickets_cents' + , '238, 132, 128' + , data) + , dataset('Total' + , 'total_cents' + , '195, 195, 195' + , data) + ] + +function dataset(label, key, rgb, data) { + return { + label: label + , data: R.pluck(key, data) + , borderWidth: 1 + , borderColor: `rgb(${rgb})` + , backgroundColor: `rgba(${rgb},0.3)` + } +} + +updateChart(defaultParams) + diff --git a/app/javascript/legacy/nonprofits/payouts/create.js b/app/javascript/legacy/nonprofits/payouts/create.js new file mode 100644 index 00000000..673f85e0 --- /dev/null +++ b/app/javascript/legacy/nonprofits/payouts/create.js @@ -0,0 +1,18 @@ +// License: LGPL-3.0-or-later +var request = require('../../common/super-agent-promise') + +module.exports = create_payout + +function create_payout(form_obj, ui) { + ui.start() + return request.post('/nonprofits/' + app.nonprofit_id + '/payouts').send({payout: form_obj}).perform() + .then(function(resp) { + ui.success(resp) + return resp + }) + .catch(function(resp) { + ui.fail(resp) + return resp + }) +} + diff --git a/app/javascript/legacy/nonprofits/payouts/index/identity-verification-form.es6 b/app/javascript/legacy/nonprofits/payouts/index/identity-verification-form.es6 new file mode 100644 index 00000000..f1bda0aa --- /dev/null +++ b/app/javascript/legacy/nonprofits/payouts/index/identity-verification-form.es6 @@ -0,0 +1,277 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const snabbdom = require('snabbdom') +const R = require('ramda') +const flyd = require('flyd') +const render = require('ff-core/render') +flyd.flatMap = require('flyd/module/flatmap') +const validatedForm = require('ff-core/validated-form') +const modal = require('ff-core/modal') +const button = require('ff-core/button') +const notification = require('ff-core/notification') +// local +const request = require('../../../common/request') +const geography = require('../../../common/geography') +const stateSelect = require('../../../components/state-selector') + + +// Form validation config for the normal info form +var messages = { + address: {state: 'Please enter a US state.'} +, business_tax_id: 'This should be 9 digits.' +} +var constraints = { + dob: { + day: { required: true , isNumber: true , min: 1 , max: 31 } + , month: { required: true , isNumber: true , min: 1 , max: 12 } + , day: { required: true , isNumber: true , min: 1900 , max: 2000 } + } +, first_name: {required: true} +, last_name: {required: true} +, address: { + city: {required: true} + , state: {required: true, includedIn: geography.stateCodes} + , line1: {required: true} + , postal_code: {required: true} + } +, business_tax_id: {required: true, format: /\d\d[- ]?\d\d\d\d\d\d\d/} +, ssn_last_4: {required: true, lenghtEquals: 4} +, phone_number: {required: true, format: /^\(?\d\d\d\)?[- ]*\d\d\d[- ]*\d\d\d\d$/} +} + +// Form validation config for the escalated form +var constraints_escalated = { + personal_id_number: {required: true, format: /\d\d\d[- ]?\d\d[- ]?\d\d\d\d/} +} +var messages_escalated = { + personal_id_number: {format: 'This should be 9 digits'} +} + + +function init() { + var state = { + regularForm: validatedForm.init({constraints, messages}) + , escalatedForm: validatedForm.init({constraints: constraints_escalated, messages: messages_escalated}) + , nonprofit: app.nonprofit + , modalID$: flyd.stream() + , error$: flyd.stream() + } + + const submitSuccess$ = flyd.merge(state.regularForm.validData$, state.escalatedForm.validData$) + const submitPath = `/nonprofits/${app.nonprofit_id}/verify_identity` + const resp$ = flyd.flatMap( + data => flyd.map(R.prop('body'), request({method: 'put', path: submitPath, send: {legal_entity: data}}).load) + , submitSuccess$ ) + flyd.map(()=> location.reload(), resp$) + const message$ = flyd.map(() => 'Successfully submitted! Reloading...', resp$) + state.notification = notification.init({message$}) + + state.loading$ = flyd.mergeAll([ + flyd.map(()=> true, submitSuccess$) + , flyd.map(()=> false, resp$) + ]) + + const status = app.nonprofit.verification_status + var n = document.querySelector('.js-openVerificationModal') + if(n) n.addEventListener('click', ev => status === 'escalated' ? state.modalID$('escalatedDialogModal') : state.modalID$('identityVerificationModal')) + + return state +} + + +const view = state => { + return h('div.verification', [ + normalDialog(state) + , escalatedDialog(state) + , formModal(state) + , escalatedForm(state) + , notification.view(state.notification) + ]) +} + + +const normalDialog = state => { + var body = h('div.modal-body', [ + h('p', 'Please complete this form to verify your identity. This information serves as an extra security measure to prevent fraud and is required by our payment processor to comply with KYC ("Know Your Customer") laws in the US.') + , h('p', 'All information submitted through this form is 256-bit SSL encrypted and is kept completely private.') + , h('p', "This information can be entered by any 'account representative' at your organization; someone from your organization who is an administrator of your CommitChange account.") + , h('hr') + , h('button.button--large', { on: {click: [state.modalID$, 'verificationFormModal']} }, ["Let's do it ", h('i.fa.fa-arrow-right')]) + ]) + + return modal({ + thisID: 'identityVerificationModal' + , id$: state.modalID$ + , title: h('h4', 'Account Identity Verification') + , body + }) +} + + +const formModal = state => { + return modal({ + thisID: 'verificationFormModal' + , id$: state.modalID$ + , title: 'Account Verification Form' + , body: formView(state) + }) +} + + +const escalatedDialog = state => { + var body = h('div', [ + h('p', `Our payment processor has requested the full social security number for the account holder. This usually happens when the given name or DOB does not exactly match the record in the social security database.`) + , h('p', `Entering the full social security number will almost always get your account verified.`) + , h('p', `Alternatively, you can re-enter the information on the basic form to either fix your information or use a different person at your org.`) + , h('hr') + , h('div.u-centered', [ + h('div', [ h('a.button', {on: {click: [state.modalID$, 'escalatedFormModal']}}, 'Enter full SSN') ]) + , h('p', 'or...') + , h('div', [ h('a.button', {on: {click: [state.modalID$, 'verificationFormModal']}}, 'Retry the basic form to correct any mistakes')]) + ]) + ]) + + return modal({ + thisID: 'escalatedDialogModal' + , id$: state.modalID$ + , title: 'Further Verification Needed' + , body + }) +} + + +const escalatedForm = state => { + var valForm = validatedForm.form(state.escalatedForm) + var field = validatedForm.field(state.escalatedForm) + + var body = valForm(h('form', {on: {submit: state.submit$}}, [ + h('label', [h('i.fa.fa-lock'), ' Full Social Security Number']) + , field(h('input', {props: {name: 'personal_id_number', type: 'text', placeholder: '9-digit number'}})) + , button({loading$: state.loading$, error$: state.error$}) + ])) + + return modal({ + thisID: 'escalatedFormModal' + , id$: state.modalID$ + , className: 'modal--flush' + , title: 'Identity Verification' + , body + }) +} + + +const formView = state => { + var field = validatedForm.field(state.regularForm) + var np = state.nonprofit + var formEl = h('form', {on: {submit: state.submit$}}, [ + h('p', [h('strong', 'Org Name: '), np.name]) + , h('div', [ + h('fieldset', {style: {display: 'inline-block', width: '48%', marginRight: '10px'}}, [ + h('label', 'Org Address') + , field(h('input', { style: {width: '95%'}, props: { type: 'text', name: 'address[line1]', value: np.address } })) + ]) + , h('fieldset', {style: {display: 'inline-block', width: '48%', marginRight: '10px'}}, [ + h('label', 'City') + , field(h('input', { props: { type: 'text', name: 'address[city]', value: np.city } })) + ]) + ]) + + , h('div', [ + h('fieldset', {style: {display: 'inline-block', width: '48%', marginRight: '10px'}}, [ + h('label', 'State') + , field(stateSelect({default: np.state_code, name: 'address[state]', value: np.state_code})) + ]) + , h('fieldset', {style: {display: 'inline-block', width: '48%', marginRight: '10px'}}, [ + h('label', 'Postal Code') + , field(h('input', { props: { type: 'text', name: 'address[postal_code]', value: np.zip_code } })) + ]) + ]) + + , h('div', [ + h('fieldset', {style: {display: 'inline-block', width: '48%', marginRight: '10px'}}, [ + h('label', 'Org Phone') + , field(h('input', {props: {type: 'text', name: 'phone_number', value: np.phone}})) + ]) + , h('fieldset', {style: {display: 'inline-block', width: '48%'}}, [ + h('label', 'Organization EIN') + , field(h('input', { props: { name: 'business_tax_id', value: np.ein } })) + ]) + ]) + + , h('hr') + , h('h6', 'Account Holder Info') + + , h('div', [ + h('fieldset.col-6', [ + h('label', 'First Name') + , field(h('input', {style: {width: '95%'}, props: {type: 'text', name: 'first_name'}})) + ]) + , h('fieldset.col-right-6', [ + h('label', 'Last Name') + , field(h('input', {props: {type: 'text', name: 'last_name'}})) + ]) + ]) + + , h('div', [ + dobField(state) + , h('fieldset.col-right-6', [ + h('label', 'Last 4 of Social Security Number') + , field(h('input', {props: {type: 'text', name: 'ssn_last_4'}})) + ]) + ]) + + , h('hr') + , h('p.finePrint.u-centered', [ + 'CommitChange processes payments using Stripe. By clicking "Submit" below, you agree to ' + , h('a', {props: {target: '_blank', href: 'https://stripe.com/connect/account-terms'}}, "Stripe's Connected Account Agreement.") + ]) + + , h('hr') + , h('div.u-centered', [ button({loading$: state.loading$, error$: state.error$}) ]) + ]) + + return validatedForm.form(state.regularForm, formEl) +} + + +// Generate a select element with a set of vals for options, a name attr, and a default option that has a null val +const selector = R.curry((name, vals, defaultOption) => + h('select', {props: {name: name}}, + R.prepend( + h('option', {props: {value: null, selected: true}}, defaultOption) + , R.map(n => h('option', {props: {value: n}}, n), vals) + ) + ) +) + + +const dobField = R.curry(state => { + var field = validatedForm.field(state.regularForm) + var fieldStyle = {width: '30%', display: 'inline-block'} + return h('fieldset.col-6', [ + h('label', 'Date of Birth') + , h('div', {style: R.merge(fieldStyle, {width: '28%'})}, [ + field(selector('dob[day]', R.range(1,32), 'Day')) + ]) + , h('strong', '/') + , h('div', {style: R.merge(fieldStyle, {width: '32%'})}, [ + field(selector('dob[month]', R.range(1, 13), 'Month')) + ]) + , h('strong', '/') + , h('div', {style: fieldStyle}, [ + field(selector('dob[year]', R.range(1900, 2000), 'Year')) + ]) + ]) +}) + + +// -- Render +var container = document.querySelector('.js-flimflam-verification') +const patch = snabbdom.init([ + require('snabbdom/modules/eventlisteners') +, require('snabbdom/modules/class') +, require('snabbdom/modules/props') +, require('snabbdom/modules/style') +]) +render({state: init(), container, view, patch}) + diff --git a/app/javascript/legacy/nonprofits/payouts/index/page.js b/app/javascript/legacy/nonprofits/payouts/index/page.js new file mode 100644 index 00000000..72704131 --- /dev/null +++ b/app/javascript/legacy/nonprofits/payouts/index/page.js @@ -0,0 +1,27 @@ +// License: LGPL-3.0-or-later +var create_payout = require('../create') +var format_err = require('../../../common/format_response_error') +appl.verify_identity = require('./verify_identity') +appl.create_bank_account = require('../../../bank_accounts/create.es6') +require('../../../bank_accounts/resend_confirmation_email') + +appl.def('create_payout', function(form_obj) { + create_payout(form_obj, new_payout_ui) +}) + + +var new_payout_ui = { + start: function() { + appl.is_loading() + }, + success: function(resp) { + appl.notify("Payout creation successful! Reloading page...") + appl.reload() + }, + fail: function(resp) { + appl.not_loading() + appl.def('error', format_err(resp)) + } +} + +require('./identity-verification-form.es6') diff --git a/app/javascript/legacy/nonprofits/payouts/index/verify_identity.js b/app/javascript/legacy/nonprofits/payouts/index/verify_identity.js new file mode 100644 index 00000000..140d3f8f --- /dev/null +++ b/app/javascript/legacy/nonprofits/payouts/index/verify_identity.js @@ -0,0 +1,24 @@ +// License: LGPL-3.0-or-later +var request = require('../../../common/super-agent-promise') +var format_err = require('../../../common/format_response_error') + +module.exports = verify_identity + +function verify_identity(form_obj) { + appl.def("identity_verification", {loading: true, error: ""}) + return request.put("/nonprofits/" + app.nonprofit_id + "/verify_identity") + .send({legal_entity: form_obj}).perform() + .then(function(resp) { + appl.def("identity_verification.loading", false) + appl.notify("Thank you! Your identity verification form was successfully saved.") + appl.close_modal() + appl.reload() + return resp + }) + .catch(function(resp) { + appl.def("identity_verification", { + loading: false, + error: format_err(resp) + }) + }) +} diff --git a/app/javascript/legacy/nonprofits/recurring_donations/index/create.js b/app/javascript/legacy/nonprofits/recurring_donations/index/create.js new file mode 100644 index 00000000..79eb716c --- /dev/null +++ b/app/javascript/legacy/nonprofits/recurring_donations/index/create.js @@ -0,0 +1,122 @@ +// License: LGPL-3.0-or-later +require('../../../components/wizard') +var format_err = require('../../../common/format_response_error') +var format = require('../../../common/format') +var request = require('../../../common/super-agent-promise') +var create_donation = require('../../../donations/create') +var create_card = require('../../../cards/create') +var formToObj = require('../../../common/form-to-object') + +var wiz = {} + + // Set the wizard's donation object to the form data + // amount, interval, time_unit, designation +wiz.set_donation = function(node) { + var data = formToObj(appl.prev_elem(node)) + var rd = data.recurring_donation + if(rd.start_date) { + rd.start_date = format.date.toStandard(rd.start_date) + } + if(rd.end_date) { + rd.end_date = format.date.toStandard(rd.end_date) + } + if(data.dollars) { + data.amount = format.dollarsToCents(data.dollars) + delete data.dollars + } + appl.def('rd_wizard.donation', data) + appl.wizard.advance('rd_wizard') +} + +// Save the supporter info. Advance immediately but save the promise. +wiz.save_supporter = function(form_obj) { + appl.wizard.advance('rd_wizard') + appl.rd_wizard.save_supporter_promise = request.post('/nonprofits/' + ENV.nonprofitID + '/supporters') + .send({supporter: form_obj}).perform() + .then(set_supporter_data) + .catch(show_err) +} + +// Resume on the supporter post promise, create a card, then create the donation with nested recurring donation +wiz.send_payment = function(card_obj) { + if(appl.rd_wizard.loading) return + appl.def('rd_wizard', {loading: true, error: ''}) + return appl.rd_wizard.save_supporter_promise + .then(function(supporter) { + return create_card({type: 'Supporter', id: supporter.id}, card_obj) + }) + .then(function(card) { + appl.rd_wizard.donation.token = card.token + return request.post('/nonprofits/' + ENV.nonprofitID + '/recurring_donations') + .send({ recurring_donation: appl.rd_wizard.donation }).perform() + }) + .then(complete_wizard) + .catch(show_err) +} + +// To be called on payment completion and a new recurring donation was successfully created +function complete_wizard() { + appl.notify("Successfully created! Reloading page...") + appl.def('loading', false) + setTimeout(()=> window.location.reload(), 1000) +} + +appl.def('rd_wizard', wiz) + +// Set the supporter values from a response to the wizard's data +function set_supporter_data(resp) { + appl.def('rd_wizard.donation', { + supporter_id: resp.body.id + }) + return resp.body +} + +// Set a general error on the wizard from an ajax response, displayed on any step +function show_err(resp) { + appl.def('rd_wizard.loading', false) + appl.def('rd_wizard.error', format_err(resp)) + throw new Error(resp) +} + +// Set all the default values for the data used in the recurring donation wizard +function set_defaults() { + appl.def('rd_wizard.donation', null) + appl.def('rd_wizard', { + donation: { + nonprofit_id: ENV.nonprofitID, + recurring_donation: { + interval: 1, + time_unit: 'month' + } + } + }) +} + +// Initialize wizard defaults +set_defaults() + + +// Initialize the pikaday date picker inputs in the various fields on the page +// jank +var Pikaday = require('pikaday') +var moment = require('moment') +var el = $('#newRecurringDonationModal') +el.find('input[name="recurring_donation.start_date"]').val(moment().format('MM-DD-YYYY')) +new Pikaday({ + field: el.find('input[name="recurring_donation.start_date"]')[0], + format: 'M/D/YYYY', + minDate: moment().toDate() +}) + +new Pikaday({ + field: el.find('input[name="recurring_donation.end_date"]')[0], + format: 'M/D/YYYY', + minDate: moment().toDate() +}) + +new Pikaday({ + field: document.querySelector('#edit_end_date'), + format: 'M/D/YYYY', + minDate: moment().toDate() +}) + diff --git a/app/javascript/legacy/nonprofits/recurring_donations/index/delete.js b/app/javascript/legacy/nonprofits/recurring_donations/index/delete.js new file mode 100644 index 00000000..42f8649d --- /dev/null +++ b/app/javascript/legacy/nonprofits/recurring_donations/index/delete.js @@ -0,0 +1,11 @@ +// License: LGPL-3.0-or-later + +appl.def('ajax_details', { + del: function(id, node) { + appl.ajax.del('recurring_donation_details', id, node).then(function(resp) { + appl.ajax.index('recurring_donations') + appl.notify("Successfully deactivated") + appl.close_side_panel() + }) + } +}) diff --git a/app/javascript/legacy/nonprofits/recurring_donations/index/index.es6 b/app/javascript/legacy/nonprofits/recurring_donations/index/index.es6 new file mode 100644 index 00000000..b32e703a --- /dev/null +++ b/app/javascript/legacy/nonprofits/recurring_donations/index/index.es6 @@ -0,0 +1,74 @@ +// License: LGPL-3.0-or-later +const snabbdom = require('snabbdom') +const flyd = require('flyd') +const h = require('snabbdom/h') +const R = require('ramda') +const modal = require('ff-core/modal') +const render = require('ff-core/render') +const request = require('../../../common/request') + +function init() { + var state = {} + state.modalID$ = flyd.stream() + state.data$ = flyd.map(R.prop('body'), request({ + path: `/nonprofits/${app.nonprofit_id}/recurring_donation_stats` + , method: 'get' + }).load) + + document + .querySelector('.js-openStatsModal') + .addEventListener('click', ev => state.modalID$('statsModal')) + + return state +} + +function view(state) { + if(!state.data$()) return h('div') + + var body = h('table.table', [ + h('tbody', [ + h('tr', [ + h('td', [h('strong', 'Active')]) + , h('td', `${state.data$().active_count} Donations`) + , h('td', `${state.data$().active_sum} Total`) + ]) + , h('tr', [ + h('td', [h('strong', 'Average Amount')]) + , h('td', `${state.data$().average} per month`) + , h('td', '') + ]) + , h('tr', [ + h('td', [h('strong', 'Cancelled')]) + , h('td', `${state.data$().cancelled_count} Donations`) + , h('td', `${state.data$().cancelled_sum} Total`) + ]) + , h('tr', [ + h('td', [h('strong', 'Charge Failures')]) + , h('td', `${state.data$().failed_count} Donations`) + , h('td', `${state.data$().failed_sum} Total`) + ]) + ]) + ]) + + return h('div', [ + modal({ + title: h('h4', 'Recurring Donations') + , id$: state.modalID$ + , thisID: 'statsModal' + , body + }) + ]) +} + + +// -- Render +const patch = snabbdom.init([ + require('snabbdom/modules/eventlisteners') +, require('snabbdom/modules/class') +, require('snabbdom/modules/props') +, require('snabbdom/modules/style') +]) +var container = document.querySelector('.js-flimflamContainer') +var state = init() +render({patch, view, container, state}) + diff --git a/app/javascript/legacy/nonprofits/recurring_donations/index/page.js b/app/javascript/legacy/nonprofits/recurring_donations/index/page.js new file mode 100644 index 00000000..a504de14 --- /dev/null +++ b/app/javascript/legacy/nonprofits/recurring_donations/index/page.js @@ -0,0 +1,50 @@ +// License: LGPL-3.0-or-later +require('./index.es6') +require('./create') +require('./update') +require('./delete') +require('../../../common/restful_resource') +require('../../../common/vendor/bootstrap-tour-standalone') +require('../../../common/panels_layout') +var format = require('../../../common/format') +appl.def('is_usa', format.geography.isUS) +require('./tour') + +appl.def('readable_interval', require('../readable_interval')) + +appl.def('recurring_donations', { + query: {page: 1}, + concat_data: true +}) + +appl.def('recurring_donations.index', function() { + appl.def('loading', true) + return appl.ajax.index('recurring_donations').then(function(resp) { + appl.def('loading', false) + if(appl.recurring_donations.query.page > 1) { + var main_panel = document.querySelector('.mainPanel') + main_panel.scrollTop = main_panel.scrollHeight + } + return resp + }) +}) + +appl.recurring_donations.index() + + + +appl.def('recurring_donation_details', { + resource_name: 'recurring_donations' +}) + + +appl.def('ajax_details', { + fetch: function(id, node) { + appl.def('loading', true) + appl.ajax.fetch('recurring_donation_details', id).then(function(resp) { + appl.open_side_panel(node) + appl.def('loading', false) + }) + }, +}) + diff --git a/app/javascript/legacy/nonprofits/recurring_donations/index/tour.js b/app/javascript/legacy/nonprofits/recurring_donations/index/tour.js new file mode 100644 index 00000000..658977c8 --- /dev/null +++ b/app/javascript/legacy/nonprofits/recurring_donations/index/tour.js @@ -0,0 +1,40 @@ +// License: LGPL-3.0-or-later +var tour_subscribers = new Tour({ + backdrop: false, + steps: [ + { + orphan: true, + title: 'Welcome to your recurring payments dashboard!', + content: "This is where all of your recurring donations will automatically appear. You can also manually add new recurring donations here with a few easy steps." + }, + { + element: '.tour-totalRecurring', + title: 'Monthly total', + placement: 'bottom', + content: 'Your recurring donations per month will be totaled here. Even if the donations are quarterly or annual, they will be calculated into this monthly balance.' + }, + { + element: '.tour-export', + placement: 'left', + title: 'Export', + content: 'If you need a report of your subscribers, use this Export button. It will download an excel file of all recurring donors.' + }, + { + element: '.tour-newSubscriber', + placement: 'left', + title: 'New subscriber button', + content: "To manually create a new custom recurring donation, use this button. You can specify the time interval as biweekly, monthly, quarterly, annual, or anything else. It's very flexible!" + }, + { + orphan: true, + title: 'Get fundraising!', + content: "Check back to this page to see your monthly total increase. Please contact support@commitchange.com if you have any questions." + } + ] +}) + +if($.cookie('tour_subscribers') === String(app.nonprofit_id)) { + $.removeCookie('tour_subscribers', {path: '/'}) + tour_subscribers.init() + tour_subscribers.restart() +} diff --git a/app/javascript/legacy/nonprofits/recurring_donations/index/update.js b/app/javascript/legacy/nonprofits/recurring_donations/index/update.js new file mode 100644 index 00000000..9f88a568 --- /dev/null +++ b/app/javascript/legacy/nonprofits/recurring_donations/index/update.js @@ -0,0 +1,15 @@ +// License: LGPL-3.0-or-later + +appl.def('ajax_details', { + update: function(id, form_obj, node) { + appl.def('loading', true) + appl.ajax.update('recurring_donation_details', id, form_obj).then(function(resp) { + appl.def('loading', false) + appl.ajax.index('recurring_donations') + appl.notify('Successfully updated!') + appl.close_modal() + appl.ajax_details.fetch(appl.recurring_donation_details.id) + }) + } +}) + diff --git a/app/javascript/legacy/nonprofits/recurring_donations/readable_interval.js b/app/javascript/legacy/nonprofits/recurring_donations/readable_interval.js new file mode 100644 index 00000000..068f78e6 --- /dev/null +++ b/app/javascript/legacy/nonprofits/recurring_donations/readable_interval.js @@ -0,0 +1,13 @@ +// License: LGPL-3.0-or-later +// Given a time interval (eg 1,2,3..) and a time unit (eg. 'day', 'week', 'month', or 'year') +// Convert it to a nice readable single interval word like 'daily', 'biweekly', 'yearly', etc.. +// If one of the above words don't exist, will return eg 'every 7 months' +module.exports = readable_interval +function readable_interval(interval, time_unit) { + if(interval === 1) return time_unit + 'ly' + if(interval === 4 && time_unit === 'year') return 'quarterly' + if(interval === 2 && time_unit === 'year') return 'biannually' + if(interval === 2 && time_unit === 'week') return 'biweekly' + if(interval === 2 && time_unit === 'month') return 'bimonthly' + else return 'every ' + appl.pluralize(Number(interval), time_unit + 's') +} diff --git a/app/javascript/legacy/nonprofits/reports/modal.js b/app/javascript/legacy/nonprofits/reports/modal.js new file mode 100644 index 00000000..cba69b2d --- /dev/null +++ b/app/javascript/legacy/nonprofits/reports/modal.js @@ -0,0 +1,58 @@ +// License: LGPL-3.0-or-later +const flyd = require('flyd') +const flyd_filter = require('flyd/module/filter') +const R = require('ramda') +const h = require('snabbdom/h') +const moment = require('moment') + +// Modal component for exporting reports + +// XXX Note: this can be generalized to be any report modal, but for now it is specific to end-of-year + +flyd.log = flyd.map(console.log.bind(console)) + +function init() { + var state = { + currentYear: moment().year() + , changeYear$: flyd.stream() + , submit$: flyd.stream() + } + const selectedYear$ = flyd_filter( + year => Number(year) <= state.currentYear && Number(year) >= 2012 + , flyd.merge( + flyd.map(ev => ev.currentTarget.value, state.changeYear$) + , flyd.stream(state.currentYear) + ) + ) + state.exportPath$ = flyd.map(year => `/nonprofits/${ENV.nonprofitID}/reports/end_of_year.csv?year=${year}`, selectedYear$) + return state +} + +function view(state) { + return h('div.modal', {props: {id: 'endOfYearReportModal'}}, [ + h('div.modal-header', [ h('h2', 'End-of-year report') ]) + , h('div.modal-body', [ + modalBody(state) + ]) + ]) +} + +const modalBody = state => { + return h('div', [ + h('p', 'Export donors who have given during a selected year, with their aggregated totals, averages, and itemized payments histories for that year.') + , h('label', 'Year') + , h('input', { + on: {change: state.changeYear$} + , props: { + type: 'number' + , placeholder: 'YYYY' + , value: state.currentYear + , min: 2012 + , max: state.currentYear + } + }) + , h('a.button', {props: {target: '_blank', href: state.exportPath$()}}, 'Download CSV Report') + ]) +} + +module.exports = {init, view} diff --git a/app/javascript/legacy/nonprofits/show/page.js b/app/javascript/legacy/nonprofits/show/page.js new file mode 100755 index 00000000..8e13c5cc --- /dev/null +++ b/app/javascript/legacy/nonprofits/show/page.js @@ -0,0 +1,88 @@ +// License: LGPL-3.0-or-later +if (app.nonprofit.brand_color) { + require('../../components/branded_fundraising') +} + +require('../../common/image_uploader') +require('../../components/fundraising/add_header_image') + +if(app.current_user) { + require('../../campaigns/new/wizard') + require('../../events/new/wizard') +} + +if(app.current_nonprofit_user) { + var editable = require('../../common/editable') + editable($('.editable'), { + placeholder: "Enter your nonprofit's story and impact here. We strongly recommend that this section is filled out with at least 250 words. It will automatically save as you type.", + sticky: $('.editable').length > 0 + }) + require('./tour') + var create_info_card = require('../../supporters/info-card.es6') + + appl.def('todos_action', '/profile_todos') + var todos = require('../../components/todos') + todos(function(data) { + appl.def('todos.items', [ + {text: "Add logo", done: data['has_logo'], modal_id: 'settingsModal' }, + {text: "Add header image", done: data['has_background'], modal_id: 'uploadBackgroundImage' }, + {text: "Add summary", done: data['has_summary'], modal_id: 'settingsModal' }, + {text: "Add images", done: data['has_image'], modal_id: 'uploadCarouselImages' }, + {text: "Add highlights", done: data['has_highlight'], modal_id: 'settingsModal' }, + {text: "Add services and impact", done: data['has_services'], link: '#js-servicesAndImpact' } + ]) + }) +} + +// -- Flimflam + +const snabbdom = require('snabbdom') +const h = require('snabbdom/h') +const flyd = require('flyd') +const R = require('ramda') +const donateWiz = require('../../nonprofits/donate/wizard') +const modal = require('ff-core/modal') +const render = require('ff-core/render') +const branding = require('../../components/nonprofit-branding') + +function init() { + var state = {} + state.donateWiz = donateWiz.init(flyd.stream({})) + state.modalID$ = flyd.stream() + return state +} + +function view(state) { + return h('section.box-r', [ + h('aside', [ + h('a.button--jumbo u-width--full', { + style: {background: branding.dark} + , on: {click: [state.modalID$, 'donationModal']} + }, [ + `Donate to ${app.nonprofit.name}` + ]) + , h('div.donationModal', [ + modal({ + thisID: 'donationModal' + , id$: state.modalID$ + , body: donateWiz.view(state.donateWiz) + // , notCloseable: state.donateWiz.paymentStep.cardForm.loading$() + }) + ]) + ]) + ]) +} + + +// -- Render + +const patch = snabbdom.init([ + require('snabbdom/modules/eventlisteners') +, require('snabbdom/modules/class') +, require('snabbdom/modules/props') +, require('snabbdom/modules/style') +]) +var container = document.querySelector('.ff-container') +var state = init() +render({container, view, patch, state}) + diff --git a/app/javascript/legacy/nonprofits/show/tour.js b/app/javascript/legacy/nonprofits/show/tour.js new file mode 100644 index 00000000..ead71f06 --- /dev/null +++ b/app/javascript/legacy/nonprofits/show/tour.js @@ -0,0 +1,25 @@ +// License: LGPL-3.0-or-later +require('../../common/vendor/bootstrap-tour-standalone') + +var profile_tour = new Tour({ + backdrop: false, + steps: [ + { + orphan: true, + title: 'Welcome to your nonprofit profile!', + content: "This is a public page where people can donate, create peer-to-peer campaigns and find out about your organization. The more you fill out this page, the richer your donors' experiences will be.", + }, + { + element: '.tour-admin', + placement: 'bottom', + title: 'Manage your profile', + content: "You can manage your profile by clicking on these buttons at the top of the page." + } + ] +}) + +if($.cookie('tour_profile') === String(app.nonprofit_id)) { + $.removeCookie('tour_profile', {path: '/'}) + profile_tour.init() + profile_tour.restart() +} diff --git a/app/javascript/legacy/nonprofits/supporter_form/index.es6 b/app/javascript/legacy/nonprofits/supporter_form/index.es6 new file mode 100644 index 00000000..a9a4200e --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporter_form/index.es6 @@ -0,0 +1,36 @@ +// License: LGPL-3.0-or-later +const flyd = require('flyd') +const R = require('ramda') +const flatMap = require('flyd/module/flatmap') +const request = require('../../common/request') +const serialize = require('form-serialize') +require('../../components/address-autocomplete') + +const submit$ = flyd.stream() +document.querySelector('.js-submit') + .addEventListener('submit', ev => { + ev.preventDefault() + submit$(ev) + }) + +flyd.map(()=> appl.def('loading', true), submit$) + +const postRequest = ev => { + return request({ + method: "POST" + , path: `/nonprofits/${app.nonprofit_id}/custom_supporter` + , send: {supporter: serialize(ev.currentTarget, {hash: true})} + }).load +} + +const getReqBody = flyd.map(R.prop('body')) + +const response$ = getReqBody(flatMap(postRequest, submit$)) + +flyd.map(()=> { + document.querySelector('.finishedMessage').className = 'finishedMessage' + document.querySelector('.js-submit').className = 'js-submit hide' +}, response$) + +flyd.map(()=> appl.def('loading', false), response$) + diff --git a/app/javascript/legacy/nonprofits/supporter_form/page.js b/app/javascript/legacy/nonprofits/supporter_form/page.js new file mode 100644 index 00000000..a3d1fc6e --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporter_form/page.js @@ -0,0 +1,2 @@ +// License: LGPL-3.0-or-later +require('./index.es6') diff --git a/app/javascript/legacy/nonprofits/supporters/create.js b/app/javascript/legacy/nonprofits/supporters/create.js new file mode 100644 index 00000000..d170f3b8 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/create.js @@ -0,0 +1,17 @@ +// License: LGPL-3.0-or-later +var request = require('../../common/super-agent-promise') + +module.exports = create_supporter + +function create_supporter(form_obj, ui) { + ui.start() + return request.post('/nonprofits/' + app.nonprofit_id + '/supporters') + .send(form_obj).perform() + .then(function(resp) { + ui.success(resp) + return resp + }) + .catch(function(resp) { + ui.fail(show_err(resp)) + }) +} diff --git a/app/javascript/legacy/nonprofits/supporters/get_name.js b/app/javascript/legacy/nonprofits/supporters/get_name.js new file mode 100644 index 00000000..e4650c1b --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/get_name.js @@ -0,0 +1,5 @@ +// License: LGPL-3.0-or-later +appl.def('get_supporter_name', function(supporter) { + if(!supporter) return '' + return supporter.name || supporter.email +}) diff --git a/app/javascript/legacy/nonprofits/supporters/import/index.es6 b/app/javascript/legacy/nonprofits/supporters/import/index.es6 new file mode 100644 index 00000000..27733ff3 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/import/index.es6 @@ -0,0 +1,262 @@ +// License: LGPL-3.0-or-later +// npm +const h = require('snabbdom/h') +const R = require('ramda') +const snabbdom = require('snabbdom') +const formSerialize = require('form-serialize') +const flyd = require('flyd') +const render = require('ff-core/render') +flyd.flatMap = R.curry(require('flyd/module/flatmap')) +flyd.filter = require('flyd/module/filter') +flyd.mergeAll = require('flyd/module/mergeall') +flyd.lift = R.curry(require('flyd/module/lift')) +flyd.switchLatest = require('flyd/module/switchlatest') +const modal = require('ff-core/modal') +const wizard = require('ff-core/wizard') +const notification = require('ff-core/notification') +const button = require('ff-core/button') +// local +const request = require('../../../common/request') +const fileInputStream = require('../../../common/file-input-stream') +const uploadFile = require('../../../common/direct-to-s3-upload.es6') +const fields = require('./regex-header-matchers') + +// The import modal UI +// Upload a CSV, match up the columns, and import! + +// open the real import modal with appl.open_modal('importModal') + +function init() { + var state = { + fileUpload$: flyd.stream() + , submitFields$: flyd.stream() + , submitImport$: flyd.stream() + , fileUploadEmail$: flyd.stream() + , error$: flyd.stream() // unused for now + } + + const fileContents$ = flyd.flatMap(ev => fileInputStream(ev.currentTarget), state.fileUpload$) + state.uploadInput$ = flyd.map(ev => ev.currentTarget, state.fileUpload$) + + // Find the first line of the CSV, which is the headers row. Get the second + // result from the match function, as that will be the parenthesized match + // group. + const headers$ = flyd.map(txt => txt.match(/^(.*)(\r?\n|\r)/)[1].split(','), fileContents$) + state.rowCount$ = flyd.map(txt => txt.match(/\r?\n|\r/g).length, fileContents$) + + // Stream of matched table/column fields based on running regexes over the haders of their files + // The matches are stored as pairs of [type, field], eg ['Supporter, 'First Name'] + state.matchedHeaders$ = flyd.map(findHeaderMatches, headers$) + + // state.submitImport$ is passed the current component state, and we just want a stream of input node objects for uploadFile + const uploaded$ = flyd.flatMap(uploadFile, state.submitImport$) + + // The matched headers with a simplified data structure to post to the server + // data structure is like {header_name => match_name} -- eg {'Donation Amount' => 'donation.amount'} + state.headerData$ = flyd.map(ev => formSerialize(ev.currentTarget, {hash: true}), state.submitFields$) + + + const importResp$ = flyd.switchLatest(flyd.lift(postImport, state.headerData$, uploaded$)) + + const emailFile$ = R.compose( + flyd.flatMap(uploadFile) + , flyd.map(ev => {ev.preventDefault(); return ev.currentTarget.querySelector('input')}) + )(state.fileUploadEmail$) + + state.loading$ = flyd.mergeAll([ + flyd.map(()=> true, state.submitImport$) // start loading + , flyd.map(()=> false, importResp$) // stop loading + , flyd.map(()=> true, state.fileUploadEmail$) + , flyd.map(()=> false, emailFile$) + ]) + + const notify$ = flyd.map( + ()=> 'Your import was successfully initiated. Feel free to upload additional files.' + , emailFile$ + ) + + // All streams that cause the wizard to advance + const wizardStep$ = flyd.mergeAll([ + flyd.stream(0) + , flyd.map(() => 1, state.fileUpload$) + , flyd.map(() => 2, state.submitFields$) + ]) + + const wizardCompleted$ = flyd.map(()=> true, importResp$) + + state.modalID$ = flyd.stream() + const jump$ = flyd.stream() + state.wizard = wizard.init({currentStep$: wizardStep$, isCompleted$: wizardCompleted$}) + state.notification = notification.init({message$: notify$}) + + // XXX using vanilla JS for the initial modal open action. This can be replaced with Flyd/Vdom when the CRM table meta is in vdom + var btnSuper = document.querySelector('.js-importButton') + if(btnSuper) btnSuper.addEventListener('click', ev => state.modalID$('importModal')) + + return state +} + + +// post to /imports after the file is uploaded to S3 +const postImport = R.curry((headers, file) => { + return flyd.map(R.prop('body'), request({ + method: 'post' + , path: `/nonprofits/${app.nonprofit_id}/imports` + , send: {file_uri: file.uri, header_matches: headers} + }).load) +}) + + +// Maps over the header strings. +// Return an array of pairs of matches like [tableName, fieldName] using +// regexes (from the fields object above) based on the column headers from the CSV +const findHeaderMatches = + R.map( + name => ({ + name: name + , match: R.find(f => R.test(f.regex, name), fields) + }) + ) + +function dontLetThemMessItUp(state) { + return modal({ + thisID: 'importDontLetThemDoIt' + , id$: state.modalID$ + , title: 'New Import' + , className: 'modal--flush' + , body: dontLetThemBody(state) + }) +} + +function dontLetThemBody(state) { + return h('div', [ + h('p', 'Upload a spreadsheet to get your import rolling. Imports will take 1-3 days depending on the data.') + , h('p', 'You can generally import any donor and supporter information along with their donation amounts, dates, designations, etc.') + , h('p', 'You will receive an email followup once the import is complete or if there were any problems with the data.') + , h('form', {on: {submit: state.fileUploadEmail$}}, [ + h('input', {props: {type: 'file', name: 'file'}}) + , h('hr') + , button({loading$: state.loading$, error$: state.error$}) + ]) + ]) +} + + +function view(state) { + var wiz = wizard.view(R.merge(state.wizard, { + steps: [ + { name: 'Upload', body: uploadStep(state) } + , { name: 'Fields', body: fieldsStep(state) } + , { name: 'Import', body: importStep(state) } + ] + , followup: finishedStep(state) + })) + + return h('div.import', [ + modal({ + thisID: 'importModal' + , id$: state.modalID$ + , title: 'New Import' + , noPad: true + , className: 'modal--flush' + , body: wiz + }) + , dontLetThemMessItUp(state) + , notification.view(state.notification) + ]) +} + + +const finishedStep = state => + h('div', [ + h('p.u-bold.u-color--green', 'Your import has successfully started.') + , h('p', "It'll take a few minutes to complete everything.") + , h('p', ["We'll send a notification message to your email at ", h('span.u-bold', app.user.email), " as soon as it's done."]) + , h('hr') + , h('div.u-centered', [ h('button.button', {on: {click: [state.modalID$, false]}}, 'Close') ]) + ]) + + +const uploadStep = state => + h('div', [ + h('p.u-bold', "First, let's upload a CSV file with the supporter and donation data you'd like to import. ") + , h('p', 'Make sure your file has column headers in the first row.') + , h('hr') + , h('form', [ + h('input', {on: {change: state.fileUpload$}, props: {type: 'file', name: 'file'}}) + ]) + ]) + + +// Modal for the user to match up CSV headers with database columns +function fieldsStep(state) { + if(!state.matchedHeaders$()) return h('div') + + return h('form', { + on: {submit: ev => {ev.preventDefault(); state.submitFields$(ev)}} + }, [ + h('p', "We've automatically detected your CSV headers. Please match up your file's column headers with our available fields.") + , h('table.table', [ + h('thead', h('tr', [h('td', 'CSV Column'), h('td', 'Import As...')])) + , h('tbody', R.map(colSelectRow, state.matchedHeaders$())) + ]) + , h('hr') + , h('div.u-centered', [ + h('button.button', 'Next') + ]) + ]) +} + + +const colSelectRow = header => + h('tr', [ + h('td', [h('strong', header.name)]) + , h('td', [h('i.fa.fa-long-arrow-right')]) + , h('td.u-padding--0', [ + h('select.u-margin--0.u-inlineBlock.u-width--full.u-marginY--5' + , { props: {name: header.name} } + , R.concat( + [ // Default options for every field + h('option', {props: {selected: !header.match, value: ''}}, 'Select Field') + , h('option', {props: {value: ''}}, 'Ignore') + , h('option', {props: {value: 'custom_field'}}, 'New Custom Field') + ] + , R.map(fieldOption(header), fields) + ) + ) + ]) + ]) + +const fieldOption = header => field => + h('option', { + props: { + value: field.import_key + , selected: header.match && header.match.name === field.name + } + }, field.name ) + + +const importStep = state => + h('div', [ + h('p', ['We will be importing the following data from ', h('strong', (state.rowCount$()-1) + ' rows'), ': ']) + , h('p.u-bold', R.join(', ', R.map(obj => obj.name, (state.matchedHeaders$() || [])))) + , h('p', "If this looks good to you, hit Submit to get the import rolling.") + , h('p', "Note that the import can always be undone later.") + , h('form.u-centered', { + on: { submit: ev => { ev.preventDefault(); state.submitImport$(state.uploadInput$())}} + }, [ button({loading$: state.loading$, error$: state.error$}) ]) + ]) + + + +// -- Render to the page + +var container = document.querySelector('#js-vdomParty') +const patch = snabbdom.init([ + require('snabbdom/modules/eventlisteners') +, require('snabbdom/modules/class') +, require('snabbdom/modules/props') +, require('snabbdom/modules/style') +]) +render({state: init(), view, container, patch}) + diff --git a/app/javascript/legacy/nonprofits/supporters/import/regex-header-matchers.js b/app/javascript/legacy/nonprofits/supporters/import/regex-header-matchers.js new file mode 100644 index 00000000..c9a9ea27 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/import/regex-header-matchers.js @@ -0,0 +1,121 @@ +// License: LGPL-3.0-or-later + +// A full list of available import keys that data can be imported into +// import_key roughly translates to 'table_name.column', but not exactly... see insert_imports.rb +// Also, the regexes allow us to automatically detect what CSV headers match what import keys +// 'regex' is the regex that we use to match on the CSV header +// 'name' is the readable name of the import key / table.column +// 'import_key' is a key name that is used to handle the importing of the data, found in insert_imports.rb + +// Automatic header matching is performed top down -- the first regex to +// successfully match is used. So put the more generic matches at the bottom, +// below the more specific matches. + +module.exports = [ + { + regex: /.*e(-)?mail *(address)?.*/i + , name: 'Donor Email' + , import_key: 'supporter.email' + } + , { + regex: /.*country.*/i + , name: 'Donor Country' + , import_key: 'supporter.country' + } + , { + regex: /.*(street[ \-_]*)?address *(line[ \-_]*2).*/i + , name: 'Donor Address (line 2)' + , import_key: 'supporter.address_line2' + } + , { + regex: /.*(street[ \-_]*)?address *(line[ \-_]*1)?.*/i + , name: 'Donor Address (line 1)' + , import_key: 'supporter.address' + } + , { + regex: /.*city.*/i + , name: 'Donor City' + , import_key:'supporter.city' + } + , { + regex: /.*(state|province)[ \-_]*(code)?.*/i + , name: 'Donor State/Region' + , import_key:'supporter.state_code' + } + , { + regex: /.*(zip|postal)[ \-_]*(code)?.*/i + , name: 'Donor Postal Code' + , import_key:'supporter.zip_code' + } + , { + regex: /.*(tele)?phone *(number)?.*/i + , name: 'Donor Phone' + , import_key:'supporter.phone' + } + , { + regex: /.*(org|organization|company) *(name)?.*/i + , name: 'Donor Company/Org' + , import_key:'supporter.organization' + } + , { + regex: /.*(donation|contributed)?[ \-_]*(amount|total).*/i + , name: 'Donation Amount' + , import_key:'donation.amount' + } + , { + regex: /.*(fund|designation|towards).*/i + , name: 'Donation Designation/Fund' + , import_key:'donation.designation' + } + , { + regex: /.*(campaign)[ \-_]*(name)?.*/i + , name: 'Donation Campaign Name' + , import_key:'donation.designation' + } + , { + regex: /.*(honorarium|dedication|in honor of|memorium|in memory of).*/i + , name: 'Donation Memorium/Dedication' + , import_key:'donation.dedication' + } + , { + regex: /.*((date)|(created(_at)?)).*/i + , name: 'Donation Date' + , import_key:'donation.date' + } + , { + regex: /.*(payment)? *kind|type|method.*/i + , name: 'Donation Payment Method' + , import_key:'offsite_payment.kind' + } + , { + regex: /.*comment|note(s?).*/i + , name: 'Additional Note/Comment' + , import_key:'donation.comment' + } + , { + regex: /.*check.*/i + , name: 'Check Number' + , import_key:'offsite_payment.check_number' + } + , { + regex: /.*tag.*/i + , name: 'New Tag' + , import_key:'tag' + } + , { + regex: /.*(^ *(first[ \-_]*)?name).*/i + , name: 'Donor First Name' + , import_key:'supporter.first_name' + } + , { + regex: /.*(^ *(last[ \-_]*)?name).*/i + , name: 'Donor Last Name' + , import_key:'supporter.last_name' + } + , { + regex: /.*(full[ \-_]*)?name.*/i + , name: 'Donor Full Name' + , import_key:'supporter.name' + } +] + diff --git a/app/javascript/legacy/nonprofits/supporters/index/action_recipient.js b/app/javascript/legacy/nonprofits/supporters/index/action_recipient.js new file mode 100644 index 00000000..df3a1c10 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/action_recipient.js @@ -0,0 +1,11 @@ +// License: LGPL-3.0-or-later +module.exports = action_recipient + +function action_recipient(){ + var total = appl.supporters.selecting_all ? appl.supporters.total_count : appl.supporters.selected.length + if (appl.supporters.selected.length <= 1) + return appl.supporters.selected[0].name || appl.supporters.selected[0].email + else + return total + ' Supporters' +} + diff --git a/app/javascript/legacy/nonprofits/supporters/index/bulk_delete.js b/app/javascript/legacy/nonprofits/supporters/index/bulk_delete.js new file mode 100644 index 00000000..27c2d172 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/bulk_delete.js @@ -0,0 +1,40 @@ +// License: LGPL-3.0-or-later +var action_recipient = require("./action_recipient") +var request = require('../../../common/client') + +appl.def('show_bulk_delete_supporters', function(){ + var total = appl.supporters.selecting_all ? appl.supporters.total_count : appl.supporters.selected.length + appl + .def('action_recipient', action_recipient()) + .def('supporters.selected_with_limit', appl.supporters.selected.slice(0,29)) + .def('supporters.remaining', total - appl.supporters.selected_with_limit.length) + .open_modal('bulkDeleteModal') +}) + + +appl.def('bulk_delete', function() { + + var post_data = {} + if (appl.supporters.selecting_all) + { + post_data.selecting_all = true + post_data.query = appl.supporters.query + } + else + { + post_data.supporter_ids = appl.supporters.selected.map(function(s) { return s.id }) + } + + appl.def('loading', true) + request.put("/nonprofits/" + app.nonprofit_id + "/supporters/bulk_delete") + .send(post_data) + .end(function(err, resp){ + appl.def('loading', false) + if(!resp.ok) return appl.notify('Sorry, we were unable to delete those supporters') + appl.notify('Supporters successfully removed') + appl.close_modal() + appl.supporters.index() + appl.def('supporters.selected', []) + }) +}) + diff --git a/app/javascript/legacy/nonprofits/supporters/index/import.js b/app/javascript/legacy/nonprofits/supporters/index/import.js new file mode 100644 index 00000000..f7ee47bb --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/import.js @@ -0,0 +1,9 @@ +// License: LGPL-3.0-or-later + +appl.def('import_data', { + after_post: function(resp) { + appl + .open_modal("importCompletedModal") + .supporters.index() + } +}) diff --git a/app/javascript/legacy/nonprofits/supporters/index/list_supporters.js b/app/javascript/legacy/nonprofits/supporters/index/list_supporters.js new file mode 100644 index 00000000..d0442899 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/list_supporters.js @@ -0,0 +1,112 @@ +// License: LGPL-3.0-or-later +const flyd = require('flimflam/flyd') // for ajaxing /index_metrics, line 27 +const request = require('../../../common/request') // for ajaxing /index_metrics +var map = require('../../../components/maps/cc_map') +var npo_coords = require('../../../components/maps/npo_coordinates')() + +appl.def('supporters.selected', []) + +appl.def('supporters.index', function() { + appl.def('loading', true) + appl.ajax.index('supporters').then(function(resp) { + appl.supporters.open_side_panel_with_params() + if(appl.supporters.selecting_all){ + set_checked(resp.body.data, true) + } + appl.def('loading', false) + var supporter_ids = appl.supporters.data.map(function(datum){return datum.id}) + appl.def('supporters.data', appl.supporters.data.map(supp => { + supp.tags_remaining = (supp.tags && supp.tags.length > 5) + ? (supp.tags.length - 5) + : false + supp.tags = supp.tags ? supp.tags.slice(0,5) : [] + return supp + })) + map.init('specific-npo-supporters', {fit_all: true}, {npo_id: app.nonprofit.id, supporter_ids: supporter_ids}) + }) + + appl.def('metrics_loading', true) + const response$ = request({ + method: 'get' + , path: `/nonprofits/${ENV.nonprofitID}/supporters/index_metrics` + , query: appl.supporters.query + }).load + const respOk$ = flyd.filter(r => r.status === 200, response$) + flyd.map(r => { appl.def('metrics_loading', false) }, respOk$) + flyd.map(r => { appl.def('supporters', r.body) }, respOk$) +}) + + +appl.def('supporters', { + query: {page: 1}, + concat_data: true, + path_prefix: '/nonprofits/' + app.nonprofit_id + '/' +}) + + +appl.def('supporters.open_side_panel_with_params', function(){ + var url_supporter_id = utils.get_param('sid') + if(url_supporter_id) { + appl.ajax.fetch('supporter_details', url_supporter_id).then(function(resp){ + appl.supporter_details.show(resp.body.data) + appl.def('loading', false) + }) + } +}) + + +appl.supporters.index() + + +appl.def('toggle_select_all', function(node) { + var checkbox = appl.prev_elem(node) + appl.def('supporters.selecting_all', checkbox.checked) + if(checkbox.checked) { // select all + appl.def('supporters.data', set_checked(appl.supporters.data, true)) + appl.def('supporters.selected', appl.supporters.data) + } +}) + + +appl.def('toggle_select_page', function(node) { + var checkbox = appl.prev_elem(node) + appl.def('supporters.selecting_all', false) + if(checkbox.checked) { // select all + appl.def('supporters.data', set_checked(appl.supporters.data, true)) + appl.def('supporters.selected', appl.supporters.data) + } else { // deselect all + appl.def('supporters.data', set_checked(appl.supporters.data, false)) + appl.def('supporters.selected', []) + } + return appl +}) + + +appl.def('toggle_supporters_checkbox', function(id, node) { + var checked = appl.prev_elem(node).checked + appl.find_and_set('supporters.data', {id: id}, {is_checked: checked}) + appl.def('supporters.selected', appl.get_checked_supporters()) + appl.def('supporters.selecting_all', false) +}) + + +appl.def('get_checked_supporters', function() { + return appl.supporters.data.filter(function(s) { return s.is_checked }) +}) + + +appl.def('uncheck_all_supporters', function() { + appl.supporters.data.forEach(function(obj){return obj.is_checked = false}) + appl.def('supporters.data', appl.supporters.data) + .def('supporters.selected', []) + .def('supporters.selecting_all', false) +}) + + +function set_checked(supporters, state) { + return supporters.map(function(s) {s.is_checked = state; return s}) +} + +appl.def('print_last_payment_before', function(last_payment_before) { + return String(last_payment_before).split('_').join(' ') +}) diff --git a/app/javascript/legacy/nonprofits/supporters/index/manage_custom_fields.js b/app/javascript/legacy/nonprofits/supporters/index/manage_custom_fields.js new file mode 100644 index 00000000..378bb099 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/manage_custom_fields.js @@ -0,0 +1,124 @@ +// License: LGPL-3.0-or-later +var request = require('../../../common/client') +var action_recipient = require('./action_recipient') +var fields = require('./tags_and_fields_shared_methods') +var type = 'custom_field' + +fields.index_masters(type) + +appl.def('custom_fields.masters.show_modal', function(){ + appl.open_modal('manageFieldMasterModal') +}) + + +appl.def('custom_fields.masters.add', function(form_obj, node){ + fields.add({ type: type, form_obj: form_obj, node: node }) +}) + + +appl.def('custom_fields.masters.delete', function(name, id, node) { + fields.delete({ name: name, id: id, type: type, node: node }) + appl.ajax.index('supporter_details.custom_fields') +}) + + +appl.def('custom_fields.bulk.show_modal', function(node) { + appl + .def('custom_fields.bulk.action_recipient', action_recipient()) + .open_modal('editBulkCustomFieldsModal') +}) + + +appl.def('custom_fields.bulk.toggle_remove', function(this_field, node) { + if (this_field.remove) this_field.remove = false; + else this_field.remove = true; + appl.def('custom_fields.masters.data', appl.custom_fields.masters.data) +}) + + +appl.def('custom_fields.bulk.prepare_to_post', function(form_obj, node) { + var fields = [] + + for(var i = 1, len = form_obj.id.length; i < len; ++i) { + if(form_obj.remove[i] === 'true') + fields.push({custom_field_master_id: form_obj.id[i], value: ''}) + else if(form_obj.val[i] === '') + {} + else + fields.push({custom_field_master_id: form_obj.id[i], value: form_obj.val[i]}) + } + + if(appl.supporters.selecting_all) + var post_data = { + custom_fields: fields, + selecting_all: true, + query: appl.supporters.query + } + else + var post_data = { + custom_fields: fields, + supporter_ids: appl.supporters.selected.map(function(s){return s.id}) + } + + post_custom_field_edits(post_data, function() { + appl + .notify('Successfully updated fields for ' + appl.custom_fields.bulk.action_recipient) + .uncheck_all_supporters() + }) + appl.def('custom_fields.masters.data', appl.custom_fields.masters.data.map(function(s) {s.remove = false; return s})) + appl.prev_elem(node).reset() +}) + + + +appl.def('custom_fields.single.show_modal', function(name, id, node) { + var custom_field_list = [] + + appl.custom_fields.masters.data.forEach(function(custom_field_master) { + var new_custom_field = { + id: custom_field_master.id, + name: custom_field_master.name + } + appl.supporter_details.custom_fields.data.forEach(function(custom_field_join) { + if(custom_field_join.name === custom_field_master.name && custom_field_join.value) + new_custom_field.value = custom_field_join.value + }) + custom_field_list.push(new_custom_field) + }) + + appl + .def('supporter_details.custom_field_list', custom_field_list) + .open_modal('editCustomFieldsModal') +}) + + + +appl.def('custom_fields.single.prepare_to_post', function(form_obj) { + var fields = [] + for(var i = 1, len = form_obj.id.length; i < len; ++i) { + fields.push({custom_field_master_id: form_obj.id[i],value: form_obj.val[i]}) + } + var post_data = { + custom_fields: fields, + supporter_ids: [appl.supporter_details.data.id] + } + + post_custom_field_edits(post_data, function() { + appl + .notify('Successfully updated fields for ' + appl.supporter_details.data.name_email_or_id) + .ajax.index('supporter_details.custom_fields') + }) +}) + +function post_custom_field_edits(post_data, callback){ + appl.def('loading', true) + request + .post('custom_field_joins/modify', post_data) + .end(function(err, resp) { + appl + .close_modal() + .def('loading', false) + callback() + }) +} + diff --git a/app/javascript/legacy/nonprofits/supporters/index/manage_tags.js b/app/javascript/legacy/nonprofits/supporters/index/manage_tags.js new file mode 100644 index 00000000..1ce48156 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/manage_tags.js @@ -0,0 +1,137 @@ +// License: LGPL-3.0-or-later +var request = require('../../../common/client') +var action_recipient = require('./action_recipient') +var tags = require('./tags_and_fields_shared_methods') +var type = 'tag' + +tags.index_masters(type) + +appl.def('tags.masters.show_modal', function(){ + appl.open_modal('manageTagMasterModal') +}) + + +appl.def('tags.masters.add', function(form_obj, node){ + tags.add({type: type, form_obj: form_obj, node: node}) +}) + + +appl.def('tags.masters.delete', function(name, id, node) { + var cb = appl.supporters.index + tags.delete({ name: name, id: id, type: type, node: node, cb: cb}) +}) + + +appl.def('tags.bulk.show_modal', function(node){ + appl.tags.masters.data.map(function(s) {s.edit_action = null; return s}) + + appl + .def('tags.masters.data', appl.tags.masters.data) + .def('tags.bulk.action_recipient', action_recipient()) + .open_modal('bulkTagEditModal') +}) + + +// sets any selected tag's edit_action attribute to add, remove or null +appl.def('tags.bulk.add_or_remove', function(i, action, node){ + var this_tag = appl.tags.masters.data[i] + if (this_tag.edit_action === action) + this_tag.edit_action = null + else + this_tag.edit_action = action + + appl.def('tags.masters.data', appl.tags.masters.data) +}) + + +// creates an array of tag objects like tags = [{tag_master_id: 123, selected: true}, ...] +// which is passed as an attribute in post_data. +// post_data gets passed as an argument to the post_tag_edits function +// which handles the ajax stuff +appl.def('tags.bulk.prepare_to_post', function() { + var tags = [] + var post_data = {} + + appl.tags.masters.data.forEach(function(s) { + if(s.edit_action === 'add') + tags.push({tag_master_id: s.id, selected: true}) + else if(s.edit_action === 'remove') + tags.push({tag_master_id: s.id, selected: false}) + }) + + post_data.tags = tags + + if(appl.supporters.selecting_all) { + post_data.selecting_all = true + post_data.query = appl.supporters.query + } + else { + post_data.supporter_ids = appl.supporters.selected.map(function(s){return s.id}) + } + + post_tag_edits(post_data, function() { + appl + .notify('Successfully updated tags for ' + (appl.supporters.selecting_all ? 'All Supporters' : appl.tags.bulk.action_recipient)) + .def('supporters.selected', []) + }) +}) + + +appl.def('tags.single.show_modal', function(node){ + // creates a tag list that adds an is_checked key + // if the current supporter has that tag + var tag_list = [] + + appl.tags.masters.data.forEach(function(master_tag) { + var new_tag = { + id: master_tag.id, + name: master_tag.name + } + appl.supporter_details.tags.data.forEach(function(supporter_tag) { + if(supporter_tag.name === master_tag.name) + new_tag.is_checked = true + }) + tag_list.push(new_tag) + }) + + appl.def('supporter_details.tag_list', tag_list) + appl.open_modal('tagEditModal') +}) + + +appl.def('tags.single.prepare_to_post', function(form_obj) { + var tags = [] + for(var i = 1, len = form_obj.id.length; i < len; ++i) { + tags.push({ + tag_master_id: form_obj.id[i], + selected: form_obj.selected[i] + }) + } + var post_data = { + tags: tags, + supporter_ids: [appl.supporter_details.data.id] + } + + post_tag_edits(post_data, function() { + appl + .notify('Successfully updated tags for ' + appl.supporter_details.data.name_email_or_id) + .def('supporters.selected', []) + .ajax.index('supporter_details.tags') + }) +}) + + +function post_tag_edits(post_data, callback){ + appl.def('loading', true) + request + .post('tag_joins/modify', post_data) + .end(function(err, resp) { + if(!resp.ok) return appl.notify(utils.print_error(resp)) + appl + .close_modal() + .def('loading', false) + .supporters.index() + if(appl.supporter_details.data) appl.ajax_supporter.fetch(appl.supporter_details.data.id) + callback() + }) +} diff --git a/app/javascript/legacy/nonprofits/supporters/index/merge_supporters.js b/app/javascript/legacy/nonprofits/supporters/index/merge_supporters.js new file mode 100644 index 00000000..c0b5177c --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/merge_supporters.js @@ -0,0 +1,79 @@ +// License: LGPL-3.0-or-later +var action_recipient = require("./action_recipient") +var request = require('../../../common/client') +require('../../../components/wizard') +var formatErr = require('../../../common/format_response_error') +const R = require('ramda') + +appl.def('merge.has_any', function(arr) { + var supporters = appl.merge.data.supporters + for(var i = 0, sup_len = supporters.length; i < sup_len; i++) { + for(var j = 0, arr_len = arr.length; j < arr_len; j++) { + var key = arr[j] + if(supporters[i][key]) { + appl.def('merge.data.has_at_least_one.' + key, true) + } + } + } +}) + +appl.def('merge.init', function(){ + if (appl.supporters.selected.length > 5) { + appl.notify("Sorry, you can't merge more than 5 records at a time.") + return + } + if (appl.supporters.selected.length < 2) { + appl.notify("Sorry, you need to select more than one record to merge.") + return + } + var ids = appl.supporters.selected.map(function(s) { return s.id }) + appl.def('loading', true) + appl.def('merge.data', '') + appl.def('merge.data.action_recipient', action_recipient()) + request.get('/nonprofits/' + app.nonprofit_id + '/supporters/merge_data') + .query({ids: ids}) + .end(function(err, res) { + appl.def('loading', false) + appl.def('merge.data.supporters', res.body) + appl.merge.has_any(['name', 'email', 'phone', 'address']) + appl.open_modal('mergeModal') + }) +}) + +appl.def('merge.set', function(form_obj, node) { + var supp = appl.merge.data.new_supporter + appl.def('merge.data.new_supporter', R.merge(supp, form_obj)) +}) + +appl.def('merge.select_address', function(supp, node) { + appl + .def('merge.data.new_supporter.address', supp.address) + .def('merge.data.new_supporter.city', supp.city) + .def('merge.data.new_supporter.state_code', supp.state_code) + .def('merge.data.new_supporter.zip_code', supp.zip_code ) + .def('merge.data.new_supporter.country', supp.country ) +}) + +appl.def('merge.submit', function(form_object, node){ + appl.def('loading', true) + + request.post("/nonprofits/" + app.nonprofit_id + "/supporters/merge") + .send({ + supporter: form_object, + supporter_ids: appl.supporters.selected.map(function(s){return s.id}) + }) + .end(function(err, resp){ + appl.def('loading', false) + if(resp.ok) { + appl + .def('supporters.selected', []) + .notify('Supporters successfully merged.') + .supporters.index() + } else { + appl.notify('Error: ' + formatErr(resp)) + } + }) + appl.close_modal() + appl.wizard.reset('merge_wiz') +}) + diff --git a/app/javascript/legacy/nonprofits/supporters/index/page.js b/app/javascript/legacy/nonprofits/supporters/index/page.js new file mode 100644 index 00000000..f0e29292 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/page.js @@ -0,0 +1,34 @@ +// License: LGPL-3.0-or-later +require('../../../common/restful_resource') +require('../../../common/panels_layout') +require('../../../components/date_range_picker') +require('../../../common/apply-pikaday') +require('./list_supporters') +require('./timeline') +require('./supporter_details') +require('./sidepanel') +require('./bulk_delete') +require('./manage_tags') +require('./manage_custom_fields') +require('../../../common/ajax/get_campaign_and_event_names_and_ids')(app.nonprofit_id) +require('./merge_supporters') +require('../import/index.es6') +require('../../../components/tables/filtering/apply_filter')('supporters') +require('./tour') + + +// Flim flam go: +require('../../../supporters') + + +// XXX cruft +appl.def('set_export_custom_fields', function(node) { + var checkbox = appl.prev_elem(node) + if (appl.supporters.query.export_custom_fields) { + appl.supporters.query.export_custom_fields += ',' + } else { + appl.supporters.query.export_custom_fields = '' + } + appl.supporters.query.export_custom_fields += checkbox.value + appl.def('supporters.query', appl.supporters.query) +}) diff --git a/app/javascript/legacy/nonprofits/supporters/index/sidepanel/generate-content.js b/app/javascript/legacy/nonprofits/supporters/index/sidepanel/generate-content.js new file mode 100644 index 00000000..b392c127 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/sidepanel/generate-content.js @@ -0,0 +1,128 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const R = require('ramda') +const marked = require('marked') + +const format = require('../../../../common/format') + +// generate titles and bodies from activity json data + +const pathPrefix = `/nonprofits/${app.nonprofit_id}` + +module.exports = exports = {} + +const viewPaymentLink = data => + h('p', [ h('a', {props: {href: `${pathPrefix}/payments?pid=${data.attachment_id}`}}, 'View payment details.') ]) + +exports.RecurringDonation = (data, state) => { + return { + title: `Paid $${format.centsToDollars(data.json_data.gross_amount)} towards a recurring donation` + , body: [ + viewDedication(data) + , h('p', `Started on ${format.date.toSimple(data.json_data.start_date)}. `) + , viewPaymentLink(data) + ] + , icon: 'fa-heart' + } +} + +const viewDedication = data => + data.json_data.dedication && data.json_data.dedication.name + ? h("p", [ + `Dedicated in ${data.json_data.dedication.type || 'honor'} of ` + , h('a', {props: {href: `/nonprofits/${ENV.nonprofitID}/supporters?sid=${data.json_data.dedication.supporter_id}`}}, data.json_data.dedication.name) + ]) + : '' + +exports.Donation = (data, state) => { + const desig = data.json_data.designation ? h('p', `Designation: ${data.json_data.designation}. `) : '' + return { + title: `Donated $${format.centsToDollars(data.json_data.gross_amount)}` + , body: [ + desig + , viewDedication(data) + , viewPaymentLink(data) + ] + , icon: 'fa-heart' + } +} + +exports.Ticket = (data, state) => { + var paren = data.json_data.gross_amount ? `(totalling $${format.centsToDollars(data.json_data.gross_amount)})` : '(for free)' + return { + title: `Redeemed ${data.json_data.quantity} tickets ${paren} for the event: ${data.json_data.event_name}` + , body: '' + , icon: 'fa-ticket' + } +} + +exports.Refund = (data, state) => { + return { + title: `Refunded $${format.centsToDollars(-data.json_data.gross_amount)}` + , body: [ + h('span', `Reason: ${format.snake_to_words(data.json_data.reason||'none')}. `) + , h('br') + , viewPaymentLink(data) + ] + , icon: 'fa-reply' + } +} + +exports.Dispute = (data, state) => { + return { + title: `This supporter disputed (made a charge-back) on their payment for $${format.centsToDollars(data.json_data.gross_amount)} on ${format.date.toSimple(data.json_data.original_date)}` + , body: [ + h('span', `Reason given: ${format.snake_to_words(data.json_data.reason||'none')}. `) + , h('br') + , viewPaymentLink(data) + ] + , icon: 'fa-ban' + } +} + +exports.SupporterNote = (data, state) => { + const action = data.created_at === data.updated_at ? 'added' : 'edited' + const canEdit = data.user_id === app.user_id + return { + title: `Note ${action}${data.json_data.user_email ? ' by ' + data.json_data.user_email : ''}` + , body: [ + h('span', {props: {innerHTML: marked(data.json_data.content ? data.json_data.content : '')}}) + , canEdit + ? h('span', [ + h('a.u-marginRight--10', {on: {click: [state.editNote$, data]}}, 'Edit ') + , h('span.u-color--red.u-pointer', {on: {click: [state.deleteNote$, data]}}, 'Delete') + ]) + : '' + ] + , icon: 'fa-pencil' + } +} + +exports.SupporterEmail = (data, state) => { + var jd = data.json_data + var canView = false + var body = [h('span', `Subject: ${jd.subject}`), h('br')] + var thread = h('a', {props: {href: '#'}, on: {click: [state.threadId$, jd.gmail_thread_id]}}, 'View thread') + + return { + title: `Email thread started by ${jd.from}` + , icon: 'fa-envelope' + , body: body + // , body: canView ? R.concat(body, thread) : R.concat(body, signIn) + } +} + +exports.OffsitePayment = (data, state) => { + const desig = data.json_data.designation ? `Designation: ${data.json_data.designation}. ` : '' + return { + title: `Donated $${format.centsToDollars(data.json_data.gross_amount)} (offsite)` + , body: [ + h('span', desig) + , desig ? h('br') : '' + , viewPaymentLink(data) + ] + , icon: 'fa-money' + } +} + + diff --git a/app/javascript/legacy/nonprofits/supporters/index/sidepanel/index.js b/app/javascript/legacy/nonprofits/supporters/index/sidepanel/index.js new file mode 100644 index 00000000..6dc50841 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/sidepanel/index.js @@ -0,0 +1,147 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const h = require('snabbdom/h') +const flyd = require('flyd') +const url$ = require('flyd-url') +const render = require('ff-core/render') +const filter = require('flyd/module/filter') +const snabbdom = require('snabbdom') +const mergeAll = require('flyd/module/mergeall') +const sampleOn = require('flyd/module/sampleon') +const queryString = require('query-string') +const notification = require('ff-core/notification') + +const request = require('../../../../common/request') +const confirm = require('../../../../components/confirmation-modal') + +const actions = require('./supporter-actions') +const activities = require('./supporter-activities') +const offsiteDonationForm = require('./offsite-donation-form') +const supporterNoteForm = require('./supporter-note-form') + +const flatMap = R.curry(require('flyd/module/flatmap')) + +const init = _ => { + var state = { + clickComposing$: flyd.stream() + , threadId$: flyd.stream() + , newNote$: flyd.stream() + , editNote$: flyd.stream() + , deleteNote$: flyd.stream() + , newDonation$: flyd.stream() + } + + const supporterID$ = R.compose( + filter(Boolean ) + , flyd.map(url => queryString.parse(url.search).sid) + )(url$) + + state.pathPrefix$ = flyd.map(constructPathPrefix, supporterID$) + + const supporterPath$ = flyd.map(id => `/nonprofits/${app.nonprofit_id}/supporters/${id}`, supporterID$) + + const supporterResp$ = R.compose( + flyd.map(x => x.body.data) + , filter(x => x.status === 200) + , flatMap(path => request({method: 'get', path}).load) + )(supporterPath$) + + state.supporter$ = flyd.merge(supporterResp$, flyd.stream({})) + + + state.offsiteDonationForm = offsiteDonationForm.init(state) + + state.editNoteData$ = flyd.merge( + flyd.map(R.always({}), state.newNote$) + , flyd.map(d => ({id: d.attachment_id, content: d.json_data.content}), state.editNote$)) + + const deleteNoteId$ = flyd.map(d => d.attachment_id, state.deleteNote$) + + state.noteAjaxMethod$ = mergeAll([ + flyd.map(R.always('post'), state.newNote$) + , flyd.map(R.always('put'), state.editNote$) + ]) + + state.supporterNoteForm = supporterNoteForm.init(state) + + state.confirmDelete = confirm.init(deleteNoteId$) + + const deleteNoteResp$ = flatMap(ajaxDeleteNote(supporterPath$, deleteNoteId$), state.confirmDelete.confirm$) + + // All streams that we want to trigger a refresh of the supporter timeline + const fetchActivitiesWith$ = mergeAll([ + state.pathPrefix$ + , state.offsiteDonationForm.saved$ + , state.supporterNoteForm.saved$ + , deleteNoteResp$ + ]) + + // Stream of activities data, using the pathPrefix$ stream, triggered by fetchActivitiesWith$ + state.activities$ = R.compose( + R.curryN(2, flatMap)(getActivities) + , sampleOn(R.__, state.pathPrefix$) + )(fetchActivitiesWith$) + + state.activities = activities.init(state) + + state.modalID$ = mergeAll([ + , flyd.map(()=> 'newSupporterNoteModal', state.editNoteData$) + , flyd.map(()=> null, state.supporterNoteForm.saved$) + ]) + + + const message$ = mergeAll([ + , flyd.map(()=> 'Successfully created a new offsite contribution', state.offsiteDonationForm.saved$) + , flyd.map(()=> `Successfully ${noteMsg(state.noteAjaxMethod$)} supporter note`, state.supporterNoteForm.saved$) + , flyd.map(()=> 'Successfully deleted supporter note', deleteNoteResp$) + ]) + + state.notification = notification.init({message$}) + + window.state = state + return state +} + +const ajaxDeleteNote = (pathPrefix$, id$) => () => { + const path = `${pathPrefix$()}/supporter_notes/${id$()}` + return request({ + method: 'delete' + , path + }).load +} + +const noteMsg = method$ => { + if(method$() === 'put') return 'edited' + if(method$() === 'post') return 'created a new' +} + +const getActivities = path => + flyd.map(req => req.body, request({path: path + 'activities', method: 'get'}).load) + +const constructPathPrefix = sid => `/nonprofits/${app.nonprofit_id}/supporters/${sid}/` + +const view = state => { + return h('div', [ + actions.view(state) + , activities.view(state) + , notification.view(state.notification) + , offsiteDonationForm.view(R.merge(state.offsiteDonationForm)) + , supporterNoteForm.view(R.merge(state.supporterNoteForm, {modalID$: state.modalID$})) + , confirm.view(state.confirmDelete, 'Are you sure you want to delete this note?') + ]) +} + +var container = document.querySelector('#js-sidePanel') + +// -- Render to the page +// render takes state, view function, patch function, and DOM container +const patch = snabbdom.init([ + require('snabbdom/modules/eventlisteners') +, require('snabbdom/modules/class') +, require('snabbdom/modules/props') +, require('snabbdom/modules/attributes') +, require('snabbdom/modules/style') +]) + +render({ patch, container , view, state: init() }) + diff --git a/app/javascript/legacy/nonprofits/supporters/index/sidepanel/offsite-donation-form.js b/app/javascript/legacy/nonprofits/supporters/index/sidepanel/offsite-donation-form.js new file mode 100644 index 00000000..a75b722a --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/sidepanel/offsite-donation-form.js @@ -0,0 +1,33 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const h = require('snabbdom/h') +const flyd = require('flyd') +const modal = require('ff-core/modal') +const button = require('ff-core/button') +const format = require('../../../../common/format') +const moment = require('moment') +const request = require('../../../../common/request') +const serialize = require('form-serialize') + +const flyd_flatMap = require('flyd/module/flatmap') +const flyd_mergeAll = require('flyd/module/mergeall') + +function init(parentState) { + var state = { + submit$: flyd.stream() + , supporter$: parentState.supporter$ + , saved$: flyd.stream() + } + + + return state +} + + +function view(state) { + + return h('div', {id$: 'offsite_donation_form_modal'}) +} + + +module.exports = {init, view} diff --git a/app/javascript/legacy/nonprofits/supporters/index/sidepanel/supporter-actions.js b/app/javascript/legacy/nonprofits/supporters/index/sidepanel/supporter-actions.js new file mode 100644 index 00000000..304d0ba3 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/sidepanel/supporter-actions.js @@ -0,0 +1,25 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const flatMap = require('flyd/module/flatmap') +const flyd = require('flyd') +const h = require('snabbdom/h') +flyd.mergeAll = require('flyd/module/mergeall') + + +const button = (text, stream) => + h('button.button--tiny.u-marginRight--10', {on: {click: stream}} + , [h('i.fa.fa-plus.u-marginRight--5') , text ]) + +const view = state => + h('section.timeline-actions.u-padding--10', [ + button('Note', state.newNote$) + , button('Email', () => { window.open(`mailto:${state.supporter$().email}`)}) + , button('Donation', () => appl.open_donation_modal(state.supporter$().id, + () => {state.offsiteDonationForm.saved$(Math.random())} + ) + ) + ] + ) + +module.exports = {view} + diff --git a/app/javascript/legacy/nonprofits/supporters/index/sidepanel/supporter-activities.js b/app/javascript/legacy/nonprofits/supporters/index/sidepanel/supporter-activities.js new file mode 100644 index 00000000..5178eed8 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/sidepanel/supporter-activities.js @@ -0,0 +1,74 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const h = require('snabbdom/h') +const flyd = require('flyd') +const moment = require('moment') +const flatMap = require('flyd/module/flatmap') +const request = require('../../../../common/request') +const flyd_mergeAll = require('flyd/module/mergeall') + +const generateContent = require('./generate-content') + +function init(parentState) { + const activitiesWithJson$ = flyd.map( + R.map(parseActivityJson) + , parentState.activities$ + ) + const response$ = flyd.merge( + flyd.stream([]) // default to empty array on pageload + , activitiesWithJson$ ) + const loading$ = flyd_mergeAll([ + flyd.map(() => false, response$) + ]) + return {response$, loading$} +} + +// Return js object if the string is json, otherwise return the string +const tryJSON = str => { + try { return JSON.parse(str) } catch(e) { return str } +} + +// Parse the cached `json_data` column for activities +// Also, parse the nested `dedicaton` json if it is present +const parseActivityJson = data => { + var json_data = JSON.parse(data.json_data || '{}') + json_data.dedication = tryJSON(json_data.dedication) + return R.merge(data, {json_data}) +} + +const view = parentState => { + var state = parentState.activities + if(state.loading$()) { + return h('div', [ + h('p.u-color--grey', [h('i.fa.fa-spin.fa-gear'), ' Loading timeline...']) + ]) + } + if(!state.loading$() && !state.response$().length) { + return h('div', [ + h('p.u-color--grey', 'No activity yet...') + ]) + } + return h('ul.timeline-activities', R.map(activityContent(parentState), state.response$())) +} + +// used to construct each activitiy list element +const activityContent = parentState => data => { + const contentFn = generateContent[data.kind] + if(!contentFn) return '' + const content = contentFn(data, parentState) + return h('li.timeline-activity', [ + h('div.timeline-activity-icon', [h(`i.fa.${content.icon}`)]) + , h('div.timeline-activity-card', [ + h('div', [ + h('small.u-color--grey', moment(data.date).format("ddd, MMMM Do YYYY")) + , h('div.u-fontSize--15', [ + h('strong', content.title) + , h('div', content.body) + ]) + ]) + ]) + ]) +} + +module.exports = {init, view} + diff --git a/app/javascript/legacy/nonprofits/supporters/index/sidepanel/supporter-note-form.js b/app/javascript/legacy/nonprofits/supporters/index/sidepanel/supporter-note-form.js new file mode 100644 index 00000000..fa786130 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/sidepanel/supporter-note-form.js @@ -0,0 +1,95 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const h = require('snabbdom/h') +const flyd = require('flyd') +const modal = require('ff-core/modal') +const button = require('ff-core/button') +const request = require('../../../../common/request') +const sampleOn = require('flyd/module/sampleon') +const serialize = require('form-serialize') +const flyd_filter = require('flyd/module/filter') +const flyd_flatMap = require('flyd/module/flatmap') +const flyd_mergeAll = require('flyd/module/mergeall') + +function init(parentState) { + var state = { + submit$: flyd.stream() + , supporter$: parentState.supporter$ + , editData$: flyd.merge(flyd.stream({}), parentState.editNoteData$) + , ajaxMethod$: parentState.noteAjaxMethod$ + } + + const sendData$ = flyd.map(formatData(state), state.submit$) + + const resp$ = flyd_flatMap(d => request(d).load, sendData$) + + state.saved$ = flyd_filter(req => req.status === 200, resp$) + + state.error$ = flyd_mergeAll([ + flyd.map(()=> null, state.submit$) + , flyd.map(req => 'Sorry! There was an error. Please try again soon.', flyd_filter(req => req.status !== 200, resp$)) + ]) + + const resetForm$ = sampleOn(state.saved$, state.submit$) + + flyd.map(x => x.reset(), resetForm$) + + state.loading$ = flyd_mergeAll([ + flyd.map(()=> true, state.submit$) + , flyd.map(() => false, resp$) + ]) + return state +} + +const formatData = state => form => { + form = serialize(form, {hash: true}) + const path = `/nonprofits/${app.nonprofit_id}/supporters/${form.supporter_id}/supporter_notes` + const id = state.editData$().id + return { + method: state.ajaxMethod$() + , path: id ? `${path}/${id}` : path + , send: {supporter_note: form} + } +} + +function view(state) { + var body = form(state) + return h('div', [ + modal({ + id$: state.modalID$ + , thisID: 'newSupporterNoteModal' + , title: (state.editData$().content ? 'Edit' : 'New') + ' Supporter Note' + , body + }) + ]) +} + +const form = state => { + return h('form', { + on: {submit: ev => {ev.preventDefault(); state.submit$(ev.currentTarget)}} + }, [ + h('p', ['You can use ', h('a', {props: {href: 'https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet', target: '_blank'}}, 'Markdown'), ' here.']) + , h('input', { + props: { + type: 'hidden' + , name: 'supporter_id' + , value: state.supporter$().id + } + }) + , h('fieldset', [ + h('textarea', { + props: { + rows: 3 + , name: 'content' + , placeholder: 'Write your note here for this supporter.' + , value: state.editData$().content || '' + } + }) + ]) + , h('div.u-centered', [ + button({loading$: state.loading$, error$: state.error$}) + ]) + ]) +} + +module.exports = {init, view} diff --git a/app/javascript/legacy/nonprofits/supporters/index/supporter_details.js b/app/javascript/legacy/nonprofits/supporters/index/supporter_details.js new file mode 100644 index 00000000..bbb2d84d --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/supporter_details.js @@ -0,0 +1,183 @@ +// License: LGPL-3.0-or-later +var request = require('../../../common/super-agent-promise') +var format_err = require('../../../common/format_response_error') +var create_offline_donation = require('../../../donations/create_offline') + +appl.def('supporter_details', { + resource_name: 'supporters', + + // Assign the selected supporter to the one clicked on + // Fetch the full data for that supporter with ajax after setting + // Show the side panel + show: function(supporter) { + appl.open_side_panel() + appl.def('supporter_details', supporter) + appl.def('supporters.selected', [supporter]) + appl.def('timeline_action', null) + appl.ajax_supporter.fetch(supporter.id) + var path = 'supporters/' + supporter.id + '/' + appl.def('supporter_details.tags.path_prefix', path) + appl.def('supporter_details.activities.path_prefix', path) + appl.def('supporter_details.supporter_notes.path_prefix', path) + appl.def('supporter_details.custom_fields.path_prefix', path) + request.get('/nonprofits/' + app.nonprofit_id + '/supporters/' + supporter.id + '/tag_joins').perform() + .then(function(r) { appl.def('supporter_details.tags', r.body) }) + appl.ajax.index('supporter_details.custom_fields') + }, + + toggle_panel: function(supporter, node) { + appl.close_modal() + var tr = node.parentNode.parentNode + + if(tr.hasAttribute('data-selected')) { + appl.close_side_panel() + tr.removeAttribute('data-selected','') + } else { + appl.supporter_details.show(supporter) + $('.mainPanel').find('tr').removeAttr('data-selected') + tr.setAttribute('data-selected','') + // add supporter_id to url param + var path = window.location.pathname + "?sid=" + supporter.id + window.history.pushState({},'supporter id', path) + } + } +}) + + +appl.def('ajax_supporter', { + update: function(id, form_obj, node) { + appl.def('loading', true) + appl.ajax.update('supporter_details', id, form_obj).then(function(resp) { + appl.def('loading', false) + appl.supporters.index() + if(resp.body.deleted) { + appl.find_and_remove('supporters.data', {id: resp.body.id}) + appl.close_side_panel() + appl.notify('Supporter successfully deleted') + } else { + appl.find_and_set('supporters.data', {id: resp.id}, resp) + appl.close_modal() + appl.notify('Supporter updated!') + } + }) + }, + + fetch: function(id) { + appl.def('loading', true) + appl.ajax.fetch('supporter_details', id).then(function(resp) { + appl.def('supporter_details.data.name_email_or_id', + resp.body.data.name || resp.body.data.fc_full_name || resp.body.data.email || 'Supporter #' + resp.body.data.id) + appl.def('supporter_details.data.websites', + resp.body.data.fc_websites ? resp.body.data.fc_websites.split(',') : false + ) + appl.def('loading', false) + }) + fetch_full_contact(id) + } +}) + +function fetch_full_contact(id){ + appl.def('supporter_details.data.full_contact', {photo: false, current_job: false, interests: false, jobs: false, social: false}) + request.get('/nonprofits/' + app.nonprofit_id + '/supporters/' + id + '/full_contact').perform() + .then(function(resp){ + var data = resp.body.full_contact + if (!data) { + appl.def('supporter_details.data.full_contact', false) + return + } + appl.def('supporter_details.data.full_contact', { + photo : data.photo && data.photo[0] ? data.photo[0].url : false + , current_job : data.orgs ? data.orgs.map(function(d){if (d.current) return d })[0] : false + , interests : data.topics + , jobs: data.orgs + , social: data.profiles + }) + }) +} + + +appl.ajax_supporter.create = function(form_obj, node) { + appl.def('supporter_details', {loading: true, error: ''}) + return request.post('/nonprofits/' + app.nonprofit_id + '/supporters').send({supporter: form_obj}).perform() + .then(function() { + appl.def('supporter_details', {loading: false}) + appl.close_modal() + appl.notify("Supporter successfully created!") + appl.supporters.index() + appl.prev_elem(node).reset() + }) + .catch(function(resp) { + appl.def('supporter_details', {error: format_err(resp), loading: false}) + }) +} + + +appl.def('supporter_details.tags', { + resource_name: 'tag_joins' +}) + +appl.def('supporter_details.custom_fields', { + resource_name: 'custom_field_joins' +}) + + +appl.def('supporter_details.activities', { + resource_name: 'activities' +}) + +appl.def('supporter_details.supporter_notes', { + resource_name: 'supporter_notes' +}) + + +// Override the default 'close_side_panel' function provided by +// panels_layout.js so we can set some extra data +var old_close_fn = appl.close_side_panel +appl.def('close_side_panel', function(){ + appl.def('supporters.selected', appl.get_checked_supporters()) + old_close_fn.apply(appl) +}) + + + +appl.def('delete_selected_supporters', function(id){ + appl.supporters.selected.forEach(function(supp) { + appl.ajax_supporter.update(supp.id, {deleted: true}) + }) + appl.close_side_panel() +}) + +appl.def('supporter_details.address_with_commas', utils.address_with_commas) + + +appl.def('create_offline_donation', function(form_obj, el) { + create_offline_donation(form_obj, createDonationUI) + .then(function(resp) { + appl.ajax.index('supporter_details.activities') + appl.prev_elem(el).reset() + }) +}) + +var createDonationUI = { + start: function(){ + appl.is_loading() + appl.def('new_offline_donation', {loading: true, error: ''}) + }, + success: function(){ + appl.not_loading() + appl.def('new_offline_donation', {loading: false}) + appl.notify("Offline donation created successfully") + appl.close_modal() + }, + fail: function(resp){ + appl.def('new_offline_donation', {loading: false, error: format_err(resp)}) + appl.def('loading', false).def('error', format_err(resp)) + } +} + +// Initialize the date picker inside the offline donation modal +var Pikaday = require('pikaday') +new Pikaday({ + field: document.querySelector('#js-offsiteDonationDate'), + format: 'M/D/YYYY' +}) diff --git a/app/javascript/legacy/nonprofits/supporters/index/tags_and_fields_shared_methods.js b/app/javascript/legacy/nonprofits/supporters/index/tags_and_fields_shared_methods.js new file mode 100644 index 00000000..ece41743 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/tags_and_fields_shared_methods.js @@ -0,0 +1,59 @@ +// License: LGPL-3.0-or-later +var request = require('../../../common/client') +var endpoint_prefix = '/nonprofits/' + app.nonprofit_id + '/' +var tags_or_fields = {} + + +tags_or_fields.master_endpoint = function(type){ + return endpoint_prefix + type + '_masters' +} + + +tags_or_fields.index_masters = function(type) { + request.get(tags_or_fields.master_endpoint(type)) + .end(function(err, resp) { + appl.def(type + 's.masters.data', appl.sort_arr_of_objs_by_key(resp.body.data, 'name')) + }) +} + + +tags_or_fields.add = function(obj){ + if(obj.form_obj.name === '') { + appl.notify('Sorry, input cannot be blank') + return + } + var loading_key ='manage_' + obj.type + 's.loading' + var data_key = obj.type + '_master' + var endpoint = endpoint_prefix + data_key + 's' + var data = {}; data[data_key] = obj.form_obj + var notify_text = (obj.type === 'tag' ? 'Tag' : 'Field') + ' "' + obj.form_obj.name + '"' + + appl.def(loading_key, true) + + request.post(endpoint, data) + .end(function(err, resp) { + tags_or_fields.index_masters(obj.type) + appl.prev_elem(obj.node).reset() + appl.def(loading_key, false) + if (resp.text === '["Duplicate tag"]') + appl.notify(notify_text + ' already exists.') + else if (resp.status != 200 ) + appl.notify('Sorry, could not process request') + else + appl.notify(notify_text + ' successfully added.') + }) +} + + +tags_or_fields.delete = function(obj){ + var notify_type = (obj.type === 'tag' ? 'Tag ' : 'Field ') + request.del(tags_or_fields.master_endpoint(obj.type) + '/' + obj.id) + .end(function(err, resp) { + tags_or_fields.index_masters(obj.type) + appl.notify(notify_type + '"' + obj.name + '" successfully deleted.') + if(obj.cb) obj.cb() + }) +} + + +module.exports = tags_or_fields \ No newline at end of file diff --git a/app/javascript/legacy/nonprofits/supporters/index/timeline.js b/app/javascript/legacy/nonprofits/supporters/index/timeline.js new file mode 100644 index 00000000..8f43e9eb --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/timeline.js @@ -0,0 +1,69 @@ +// License: LGPL-3.0-or-later + +appl.def('timeline.make_clickable', function(node){ + var card = appl.prev_elem(node) + card.setAttribute('clickable', '') +}) + +appl.def('timeline.show_email', function(email, date) { + email.date = date + set_readonly_email(email) + appl.open_modal('emailReadOnlyModal') +}) + +appl.def('timeline.show_note', function(note, date) { + appl.def('current_note', { + date: date, + content: note.content, + id: note.id, + is_editing: false + }) + appl.open_modal('noteModal') +}) + +function set_readonly_email(email) { + appl.def('timeline.displaying_email', { + body: email.body.replace(/{{NAME}}/g, appl.supporter_details.data.name_email_or_id), + subject: email.subject, + date: email.date + }) +} + + +appl.def('ajax_supporter_notes', { + create: function(form_obj, node) { + appl.is_loading() + appl.ajax.create('supporter_details.supporter_notes', form_obj).then(function(resp) { + appl.not_loading() + if(!resp.ok) return appl.notify("Sorry! Unable to post note: " + resp.body) + appl.def('timeline_action', null) + appl.ajax.index('supporter_details.activities') + appl.notify("Note added") + node.parentNode.reset() + }) + }, + update: function(form_obj) { + appl.is_loading() + appl.ajax.update('supporter_details.supporter_notes', form_obj['id'], form_obj).then(function(resp) { + appl.not_loading() + if(!resp.ok) return appl.notify("Sorry! Unable to update note: " + resp.body) + appl.ajax.index('supporter_details.activities') + appl.notify("Note updated") + }) + }, + delete: function(id) { + appl.is_loading() + appl.close_modal() + appl.ajax.del('supporter_details.supporter_notes', id).then(function(resp) { + appl.not_loading() + if(!resp.ok) return appl.notify("Sorry! Unable to delete note: " + resp.body) + appl.ajax.index('supporter_details.activities') + appl.notify("Note deleted") + }) + } +}) + +appl.def('get_donation_url', function(donation) { + var search_id = (donation && donation.payment && donation.payment.id) ? ('?pid=' + donation.payment.id) : ('?sid=' + appl.supporter_details.id) + return "/nonprofits/" + app.nonprofit_id + "/payments" + search_id +}) diff --git a/app/javascript/legacy/nonprofits/supporters/index/tour.js b/app/javascript/legacy/nonprofits/supporters/index/tour.js new file mode 100644 index 00000000..b1a02e2c --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/index/tour.js @@ -0,0 +1,86 @@ +// License: LGPL-3.0-or-later +require('../../../common/vendor/bootstrap-tour-standalone') + +var supporters_tour = new Tour({ + backdrop: false, + steps: [ + { + orphan: true, + title: 'Welcome to your supporters dashboard!', + content: "This page is the hub for all of your supporter data and supporter related actions, such as emailing, tagging, merging, adding notes and more. You'll notice that there is not much data here yet. Click 'Next' to find out how to import supporter data." + }, + { + orphan: true, + title: 'Importing supporter data 1/3', + content: "There are three ways that supporter data can be added to the CRM. The first method is automatic - whenever anyone makes a donation, contributes to a campaign or buys a ticket to an event, their information is automatically added here. That means no more tedious data entry for you or your team." + }, + { + element: '.tour-addSupporter', + placement: 'left', + title: 'Importing supporter data 2/3', + content: "The second method is manually - you can simply add a supporter by clicking on this button and adding the new supporter's info into a form." + }, + { + element: '.tour-import', + placement: 'left', + title: 'Importing supporter data 3/3', + content: "The third method is to import your supporter data. You can click this button and we'll walk you through the import process. " + }, + { + element: '.tour-supporters', + placement: 'top', + title: 'Supporter profile 1/2', + content: "Clicking on a supporter's row will open up that supporter's panel. The supporter panel shows that supporter's details, a timeline of their activities and actions." + }, + { + element: '.sidePanel', + placement: 'left', + title: 'Supporter profile 2/2', + onShow: openSidePanel, + onHide: appl.close_side_panel, + content: "From the supporter panel, you can edit the supporter's fields, add notes, tag, send an email or add an offline donation. All activity tied to the supporter, such as sending an email or attending an event, will automatically be added to their timeline. In addition, if the supporter has any publicly available social media data, we will add it to this panel." + }, + { + element: '.tour-bulk', + placement: 'bottom', + title: 'Bulk actions', + onShow: showBulkActions, + onHide: hideBulkActions, + content: "To access your bulk actions, just click on the supporters' checkboxes that you want to perform the bulk action on. To perform a bulk action on all of your supporters, click the checkbox on the top left corner." + }, + { + orphan: true, + title: 'Need more help?', + content: "There are still more features in our CRM that we weren't able to cover on this tour, such as creating email templates and adding donate buttons to your supporter emails. If you want a walk-through of any features or have any questions or comments, please email support@commitchange.com. We're here to help." + } + ] +}) + +if($.cookie('tour_supporters') === String(app.nonprofit_id)) { + $.removeCookie('tour_supporters', {path: '/'}) + supporters_tour.init() + supporters_tour.restart() +} + + +function openSidePanel(){ + if(!appl.supporters.data){return} + appl.def('supporter_details.data', appl.supporters.data[0]) + appl.open_side_panel() +} + +function showBulkActions(){ + if(!appl.supporters.data){return} + appl.def('supporters.data', set_checked(appl.supporters.data, true)) + appl.def('supporters.selected', appl.supporters.data) +} + +function hideBulkActions(){ + appl.def('supporters.data', set_checked(appl.supporters.data, false)) + appl.def('supporters.selected', '') +} + +function set_checked(supporters, state) { + return supporters.map(function(s) {s.is_checked = state; return s}) +} + diff --git a/app/javascript/legacy/nonprofits/supporters/new/page.js b/app/javascript/legacy/nonprofits/supporters/new/page.js new file mode 100644 index 00000000..b7ec2328 --- /dev/null +++ b/app/javascript/legacy/nonprofits/supporters/new/page.js @@ -0,0 +1,24 @@ +// License: LGPL-3.0-or-later +var restful_resource = require('../../../common/restful_resource') + +appl.def('supporter', { + path_prefix: '/nonprofits/' + app.nonprofit_id + '/', + resource_name: 'supporters', + after_create_failure: function(resp) { + appl.def('error', resp).def('loading', false) + }, + before_create: function(obj) { + obj.tags_attributes = [{ + parent_id: app.nonprofit_id, + parent_type: 'Nonprofit', + name: 'volunteer' + }] + appl.def('error', '').def('loading', true) + }, + after_create: function(resp, node){ + appl.def('loading', false) + appl.notify("Volunteer created!") + appl.redirect('/nonprofits/' + app.nonprofit_id) + } +}) + diff --git a/app/javascript/legacy/page.js b/app/javascript/legacy/page.js new file mode 100644 index 00000000..080ee951 --- /dev/null +++ b/app/javascript/legacy/page.js @@ -0,0 +1,75 @@ +// License: LGPL-3.0-or-later +// vendor +window.utils = require('./common/utilities') // XXX remove +window.appl = require('./common/application_view') // XXX remove + +window.$ = require('jquery') // XXX remove +window.jQuery = window.$ // XXX remove +require('./common/polyfills') +require('./common/vendor/jquery.cookie') // XXX remove +require('parsleyjs') // XXX remove +require('./common/jquery_additions') // XXX remove +require('./common/autosubmit') // XXX remove + +// Application-wide concerns + +// Use the proper CSRF token on every ajax request using jQuery. +// XXX remove +$.ajaxSetup({ headers: { 'X-CSRF-Token': window._csrf } }) +appl.def('csrf', window._csrf) + +// The 'notice' cookie is used for one-time messages (just like flash[:notice] in the session) +// XXX remove +if ($.cookie('notice') || $.cookie('notice') === '') { + $.removeCookie('notice', {path: '/'}) +} if ($.cookie('error') || $.cookie('error') === '') { + $.removeCookie('error', {path: '/'}) +} + +// Input clear button -- put after the input +// XXX remove +$('.clear-input').click(function(e) { + $(this).prev().val('').trigger('change') +}) + + +// XXX remove +$('*[open-modal]').click(function(e) { + e.preventDefault() + var el = e.currentTarget + $('.modal').removeClass('inView') + $('body').addClass('is-showingModal') + + if((el.hasAttribute('data-when-confirmed') || el.hasAttribute('data-when-signed-in')) && !app.user) + $('#signUpModal').addClass('inView') + else if(el.hasAttribute('data-when-confirmed') && app.user && !app.user.confirmed) + $('#emailConfirmationModal').addClass('inView') + else + $('#' + this.getAttribute('open-modal')).addClass('inView') +}) + +// XXX remove +$('body').on('click', '.modal-backdrop', function() { + $('body').removeClass('is-showingModal') + $('.modal').removeClass('inView') +}) + +// XXX remove +$("*[tooltip]").each(function() { $(this).tooltip() }) + +// XXX remove +$('.sortArrows').click(function() { + var $sortArrows = $(this) + var sort = $sortArrows.attr('sort') + if (sort === 'desc') $sortArrows.attr('sort', 'asc') + else if (sort === 'asc') $sortArrows.attr('sort', 'none') + else $sortArrows.attr('sort', 'desc') +}) + +// Hide server-side flash notice message after 7s +const flash = document.querySelector('.flash') +if(flash) { + setTimeout(function() { + flash.className = flash.className + ' u-hide' + }, 7000) +} diff --git a/app/javascript/legacy/pages/show/index.js b/app/javascript/legacy/pages/show/index.js new file mode 100644 index 00000000..527cc1ed --- /dev/null +++ b/app/javascript/legacy/pages/show/index.js @@ -0,0 +1,5 @@ +// License: LGPL-3.0-or-later +var editable = require('../../common/editable') + +if(app.current_admin) + editable($('.editable'), {sticky: true}) diff --git a/app/javascript/legacy/recurring_donations/edit/amount-step.es6 b/app/javascript/legacy/recurring_donations/edit/amount-step.es6 new file mode 100644 index 00000000..c6596d96 --- /dev/null +++ b/app/javascript/legacy/recurring_donations/edit/amount-step.es6 @@ -0,0 +1,111 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const R = require('ramda') +const flyd = require('flyd') +const format = require('../../common/format') +flyd.scanMerge = require('flyd/module/scanmerge') + +function init(donationDefaults, params$) { + var state = { + params$: params$ + , evolveDonation$: flyd.stream() // Stream of objects that can be used to R.evolve the initial donation object + , buttonAmountSelected$: flyd.stream(true) // Whether the button or input is selected + , currentStep$: flyd.stream() + } + + // A stream of objects that an be used to modify the existing donation by using R.evolve + donationDefaults = R.merge(donationDefaults, { + amount: format.dollarsToCents(state.params$().single_amount || 0) + , recurring: state.params$().type === 'recurring' + }) + // Apply R.evolve using every value on the evolveDonation$ stream, starting with the defaults + state.donation$ = flyd.scanMerge([ + [state.params$ || flyd.stream(), setDonationFromParams] + , [state.evolveDonation$, R.flip(R.evolve)] + ], donationDefaults) + + return state +} + +const setDonationFromParams = (donation, params) => { + if(params.single_amount) donation.amount = format.dollarsToCents(params.single_amount) + if(params.designation) donation.designation = params.designation + donation.recurring = params.type === 'recurring' + return donation +} + +function view(state) { + const isRecurring = state.donation$().recurring + return h('div.wizard-step.amount-step', [ + chooseNewDonationAmount() + , amountFields(state) + ]) +} + +// If recurring, an extra message to reinforce that it is in fact charged every month +function recurringMessage(isRecurring, state) { + if(!isRecurring) return '' + return h('section.donate-recurringMessage.group', [ + h('p.u-paddingX--5.u-centered', { + class: {'u-hide': !isRecurring} + }, [ + state.params$().single_amount ? '' : h('small', [ + 'Select an amount for your ' + , h('strong', 'monthly') + , ' contribution' + ]) + ]) + ]) +} + + +function chooseNewDonationAmount() { + + return h('section.donate-recurringMessage.group', [ + h('p.u-paddingX--5.u-centered', 'Choose your new donation amount') + ]) +} + +// All the buttons and the custom input for the amounts to select +function amountFields(state) { + if(state.params$().single_amount) return '' + return h('div.u-inline.fieldsetLayout--three--evenPadding', [ + h('span', + R.map( + amt => h('fieldset', [ + h('button.button.u-width--full.white.amount', { + class: {'is-selected': state.buttonAmountSelected$() && state.donation$().amount === amt*100} + , on: {click: ev => { + state.evolveDonation$({amount: R.always(format.dollarsToCents(amt))}) + state.buttonAmountSelected$(true) + state.currentStep$(1) // immediately advance steps when selecting an amount button + } } + }, [ + h('span.dollar', '$') + , String(amt) + ]) + ]) + , state.params$().custom_amounts || [] ) + ) + , h('fieldset.prepend--dollar', [ + h('input.amount', { + props: {name: 'amount', step: 'any', type: 'number', min: 1, placeholder: 'Custom'} + , class: {'is-selected': !state.buttonAmountSelected$()} + , on: { + focus: ev => state.buttonAmountSelected$(false) + , change: ev => state.evolveDonation$({amount: R.always(format.dollarsToCents(ev.currentTarget.value))}) + } + }) + ]) + , h('fieldset', [ + h('button.button.u-width--full', { + props: {type: 'submit', disabled: !state.donation$().amount || state.donation$().amount <= 0} + , on: {click: [state.currentStep$, 1]} + }, 'Next') + ]) + ]) +} + + + +module.exports = {view, init} \ No newline at end of file diff --git a/app/javascript/legacy/recurring_donations/edit/branded-wizard.es6 b/app/javascript/legacy/recurring_donations/edit/branded-wizard.es6 new file mode 100644 index 00000000..6b661988 --- /dev/null +++ b/app/javascript/legacy/recurring_donations/edit/branded-wizard.es6 @@ -0,0 +1,51 @@ +// License: LGPL-3.0-or-later + +const gradient = require('../../common/css-gradient') +const customBranding = require('./custom-nonprofit-branding.es6') + +const bg = color => `background-color: ${color} !important;` + +module.exports = function (brand_color = null) { + var colors = customBranding(brand_color) + + return ` +.wizard-steps div.is-selected, +.wizard-steps button.is-selected { + ${bg(colors.lighter)} +} +.wizard-steps .button.white { + color: #494949; +} +.wizard-steps a:not(.button--small), +.ff-wizard-index-label.ff-wizard-index-label--accessible, +.wizard-index-label.is-accessible { + color: ${colors.dark} !important; +} +.wizard-steps input.is-selected { + border-color: ${colors.light} !important; +} +.wizard-steps button:not(.white):not([disabled]) { + ${bg(colors.dark)} +} +.wizard-steps .highlight { + ${bg(colors.lightest)} +} +.wizard-steps label, +.wizard-steps th { + color: #636363; +} + +.wizard-steps input[type='radio']:checked + label:before { + ${bg(colors.base)} +} + +.wizard-steps input[type='checkbox'] + label:before { + color: ${colors.base} !important; +} + +.ff-wizard-index-label.ff-wizard-index-label--current, +.wizard-index-label.is-current { + ${gradient('left', '#fbfbfb', colors.light)} +} +` +} diff --git a/app/javascript/legacy/recurring_donations/edit/card-form.es6 b/app/javascript/legacy/recurring_donations/edit/card-form.es6 new file mode 100644 index 00000000..4e89742e --- /dev/null +++ b/app/javascript/legacy/recurring_donations/edit/card-form.es6 @@ -0,0 +1,186 @@ +// License: LGPL-3.0-or-later +// npm +const h = require('snabbdom/h') +const R = require('ramda') +const validatedForm = require('ff-core/validated-form') +const button = require('ff-core/button') +const flyd = require('flyd') +flyd.flatMap = require('flyd/module/flatmap') +flyd.filter = require('flyd/module/filter') +flyd.mergeAll = require('flyd/module/mergeall') +const scanMerge = require('flyd/module/scanmerge') +// local +const request = require('../../common/request') +const formatErr = require('../../common/format_response_error') +const createCardStream = require('../../cards/create-frp.es6') +const serializeForm = require('form-serialize') +const luhnCheck = require('../../common/credit-card-validator.js') + +// A component for filling out card data, validating it, saving the card to +// stripe, and then saving a tokenized copy to our servers. + +// Form validation constraints, validator functions, and error messages: +var constraints = { + address_zip: {required: true} +, name: {required: true} +, number: {required: true, cardNumber: true} +, exp_month: {required: true, format: /\d\d?/} +, exp_year: {required: true, format: /\d\d?/} +, cvc: {required: true, format: /\d\d\d\d?/} +} +var validators = { cardNumber: luhnCheck } +var messages = { + number: { + required: "Please enter your card number" + , cardNumber: "That card number doesn't look right" + } +} + +// You can pass in the .hideButton boolean if you want to control whether the submit button is shown/hidden +// Pass in a .card object, which can have default objects for the card form (name, number, cvc, exp_month, etc) +// Pass in .path to set the endpoint for saving the card +// Pass in .payload for default data to send to the server for every card save request (such as a request token) +const init = (state) => { + state = state || {} + // set defaults + state = R.merge({ + payload$: flyd.stream(state.payload || {}) + , path$: flyd.stream(state.path || '/cards') + , outerError$: state.error$ || flyd.stream() + }, state) + + state.form = validatedForm.init({constraints, validators, messages}) + state.card$ = flyd.merge(flyd.stream(state.card || {}), state.form.validData$) + + // streams of stripe tokenization responses + const stripeResp$ = flyd.flatMap(createCardStream, state.form.validData$) + state.stripeRespOk$ = flyd.filter(r => !r.error, stripeResp$) + const stripeError$ = flyd.map(r => r.error.message, flyd.filter(r => r.error, stripeResp$)) + + // Save the card as a card table on our own db + // streams of responses + state.resp$ = flyd.flatMap( + resp => saveCard(state.payload$(), state.path$(), resp) // cheating on the streams here.. + , state.stripeRespOk$ ) + + const ccError$ = flyd.map(R.prop('error'), flyd.filter(resp => resp.error, state.resp$)) + state.saved$ = flyd.filter(resp => !resp.error, state.resp$) + state.error$ = flyd.merge(stripeError$, ccError$,state.outerError$) + + state.loading$ = scanMerge([ + [state.form.validSubmit$, R.always(true)] + , [state.error$, R.always(false)] + , [state.saved$, R.always(false)] + ], false) + + return state +} + + +// -- Stream-related functions + +// Save the card to our own servers, and return a response stream +const saveCard = (send, path, resp) => { + send.card = R.merge(send.card, { + cardholders_name: resp.name + , name: `${resp.card.brand} *${resp.card.last4}` + , stripe_card_token: resp.id + , stripe_card_id: resp.card.id + }) + return flyd.map(R.prop('body'), request({ path, send, method: 'post' }).load) +} + + +// -- Virtual DOM + +const view = state => { + var field = validatedForm.field(state.form) + return validatedForm.form(state.form, h('form.cardForm', [ + h('div.u-background--grey.group.u-padding--8', [ + nameInput(field, state.card$().name) + , numberInput(field) + , cvcInput(field) + , expMonthInput(field) + , expYearInput(field) + , zipInput(field, state.card$().address_zip) + , profileInput(field, app.profile_id) // XXX global + ]) + , h('div.u-centered.u-marginTop--20', [ + state.hideButton ? '' : button({ + error$: state.hideErrors ? flyd.stream() : state.error$ + , loading$: state.loading$ + }) + , h('p.u-fontSize--12.u-marginBottom--0.u-marginTop--10.u-color--grey', [ h('i.fa.fa-lock'), " Transactions secured with 256-bit SSL"]) + ]) + ]) ) +} + + +const nameInput = (field, name) => + h('fieldset', [ field(h('input', { props: { name: 'name' , value: name || '', placeholder: "Cardholder's Name" } })) ]) + + +const numberInput = field => + h('fieldset.col-8', [ field(h('input', {props: { type: 'text' , name: 'number' , placeholder: 'Card Number' } })) ]) + + +const cvcInput = field => + h('fieldset.col-right-4.u-relative', [ + field(h('input', { props: { name: 'cvc' , placeholder: 'CVC' } } )) + , h('img.security-code-image', { + src: `${app.asset_path}/graphics/cc-security-code.png` + }) + ]) + + +const expMonthInput = field => { + var options = R.prepend( + h('option.default', {props: {value: undefined, selected: true}}, 'Month') + , R.range(1, 13).map(n => h('option', String(n))) + ) + return h('fieldset.col-4.u-margin--0', [ + field(h('select.select' + , { props: {name: 'exp_month'} } + , options)) + ]) +} + + +const expYearInput = field => { + var yearRange = R.range(new Date().getFullYear(), new Date().getFullYear() + 15) + var options = R.prepend( + h('option.default', {props: {value: undefined, selected: true}}, 'Year') + , R.map(y => h('option', String(y)), yearRange) + ) + return h('fieldset.col-left-4.u-margin--0', [ + field(h('select.select' + , {props: {name: 'exp_year'}} + , options)) + ]) +} + + +const zipInput = (field, zip) => + h('fieldset.col-right-4.u-margin--0', [ + field(h('input' + , { props: { + type: 'text' + , name: 'address_zip' + , value: zip || '' + , placeholder: 'Zip Code' + }} + )) + ]) + + +const profileInput = (field, profile_id) => + field(h('input' + , { props: { + type: 'hidden' + , name: 'profile_id' + , value: profile_id || '' + }} + )) + +module.exports = {view, init} + diff --git a/app/javascript/legacy/recurring_donations/edit/change-amount-wizard.es6 b/app/javascript/legacy/recurring_donations/edit/change-amount-wizard.es6 new file mode 100644 index 00000000..322f6c8b --- /dev/null +++ b/app/javascript/legacy/recurring_donations/edit/change-amount-wizard.es6 @@ -0,0 +1,132 @@ +// License: LGPL-3.0-or-later +const flyd = require('flyd') +const R = require('ramda') +const h = require('snabbdom/h') +const url = require('url') +const render = require('ff-core/render') +const wizard = require('ff-core/wizard') +const scanMerge = require('flyd/module/scanmerge') +flyd.mergeAll = require('flyd/module/mergeall') +flyd.flatMap = require('flyd/module/flatmap') +flyd.zip = require('flyd-zip') + +const getParams = require('./get-params') + +const paymentStep = require('./payment-step.es6') +const amountStep = require('./amount-step.es6') +const followupStep = require('./followup-step') + +const request = require('../../common/request') +const format = require('../../common/format') + +const brandedWizard = require('./branded-wizard.es6') +const renderStyles = require('../../components/styles/render-styles') + + + +// pass in a stream of configuration parameters +const init = params => { + var state = { + error$: flyd.stream() + , loading$: flyd.stream() + , clickFinish$: flyd.stream() + , params$: flyd.map(getParams, flyd.stream(params)) + } + + renderStyles()(brandedWizard(state.params$().nonprofit.brand_color ? state.params$().nonprofit.brand_color : null)) + + app.campaign = app.campaign || {} // so we don't have to hot switch all the calls to app.campaign.name, etc + var donationDefaults = setDonationFromParams({ + nonprofit_id: app.nonprofit_id + , campaign_id: app.campaign.id + , event_id: app.event_id + }, state.params$()) + + state.amountStep = amountStep.init(donationDefaults, state.params$) + + state.donation$ = scanMerge([ + [state.amountStep.donation$, R.merge] + + , [state.params$, setDonationFromParams] + + ], donationDefaults ) + + state.paymentStep = paymentStep.init(state.params$, state.donation$) + + const currentStep$ = flyd.mergeAll([ + state.amountStep.currentStep$ + , flyd.map(R.always(0), state.params$) // if the params ever change, jump back to step one + , flyd.stream(0) + ]) + state.wizard = wizard.init({currentStep$, isCompleted$: state.paymentStep.success$}) + + + // Handle the Finish button from the followup step -- will close modal, redirect, or refresh + flyd.lift( + (ev, params) => { + if(!parent) return + if(params.redirect) parent.postMessage(`commitchange:redirect:${params.redirect}`, '*') + else if(params.mode !== 'embedded') parent.postMessage('commitchange:close', '*') + } + , state.clickFinish$, state.params$ ) + + return state +} + +const setDonationFromParams = (don, params) => { + if(!params.single_amount || isNaN(format.dollarsToCents(params.single_amount))) delete params.single_amount + return R.merge({ + amount: params.single_amount ? format.dollarsToCents(params.single_amount) : 0 + }, don) +} + + +const view = state => { + return h('div', { + // class: {'is-modal': state.params$().offsite} + }, [ + // h('img.closeButton', { + // props: {src: '/assets/ui_components/close.svg'} + // , on: {click: ev => state.params$().offsite ? parent.postMessage('commitchange:close', '*') : null} + // , class: {'u-hide': !state.params$().offsite} + // }) + h('div.titleRow', [ + h('img', {props: {src: app.pageLoadData.nonprofit.logo.normal.url}}) + , h('div.titleRow-info', [ + h('h2', app.pageLoadData.nonprofit.name ) + , h('p', [ + state.params$().designation && !state.params$().single_amount + ? headerDesignation(state) + : app.pageLoadData.nonprofit.tagline || '' + ]) + ]) + ]) + , wizardWrapper(state) + ]) +} + +const headerDesignation = state => { + return h('span', [ + h('i.fa.fa-star', {style: {color: app.nonprofit.brand_color || ''}}) + , h('strong', ' Designation: ') + , String(state.params$().designation) + , state.params$().designation_desc + ? h('span', [h('br'), h('small', state.params$().designation_desc)]) + : '' + ]) +} + +const wizardWrapper = state => { + return h('div.wizard-steps.donation-steps', [ + wizard.view(R.merge(state.wizard, { + steps: [ + {name: 'Amount', body: amountStep.view(state.amountStep)} + , {name: 'Confirm Card', body: paymentStep.view(state.paymentStep)} + + ] + , followup: followupStep.view(state) + })) + ]) +} + +module.exports = {view, init} \ No newline at end of file diff --git a/app/javascript/legacy/recurring_donations/edit/custom-nonprofit-branding.es6 b/app/javascript/legacy/recurring_donations/edit/custom-nonprofit-branding.es6 new file mode 100644 index 00000000..3d906cd2 --- /dev/null +++ b/app/javascript/legacy/recurring_donations/edit/custom-nonprofit-branding.es6 @@ -0,0 +1,4 @@ +// License: LGPL-3.0-or-later +import nonprofitBranding from '../../../../../javascripts/src/lib/nonprofitBranding'; + +module.exports = nonprofitBranding diff --git a/app/javascript/legacy/recurring_donations/edit/followup-step.js b/app/javascript/legacy/recurring_donations/edit/followup-step.js new file mode 100644 index 00000000..26f82491 --- /dev/null +++ b/app/javascript/legacy/recurring_donations/edit/followup-step.js @@ -0,0 +1,21 @@ +// License: LGPL-3.0-or-later + +const h = require('snabbdom/h') + +function view(state) { + const supp = state.params$().supporter + return h('div.u-padding--10.u-centered', [ + h('h6.u-marginTop--15', 'Your donation was successful!') + , supp ? h('p', `A receipt will be emailed to ${supp.email}`) : '' + , h('hr') + , h('p', state.thankyou_msg || `${state.params$().nonprofit.name} appreciates your support!`) + // Show the 'finish' button only if we're in an offsite embedded modal + , h('div', [ + h('button.button', {on: {click: state.clickFinish$}}, 'Finish') + ]) + + ]) +} + + +module.exports = {view} diff --git a/app/javascript/legacy/recurring_donations/edit/get-params.js b/app/javascript/legacy/recurring_donations/edit/get-params.js new file mode 100644 index 00000000..813759f6 --- /dev/null +++ b/app/javascript/legacy/recurring_donations/edit/get-params.js @@ -0,0 +1,23 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') + +const splitParam = str => + R.split(/[_;,]/, str) + +module.exports = params => { + const defaultAmts = '10,25,50,100,250,500,1000' + // Set defaults + const merge = R.merge({ + custom_amounts: '' + }) + // Preprocess data + const evolve = R.evolve({ + multiple_designations: splitParam + , custom_amounts: amts => R.compose(R.map(Number), splitParam)((amts instanceof String ? amts : R.map(x => x/100, amts).join(',')) || defaultAmts) + , custom_fields: fields => R.map(f => { + const [name, label] = R.map(R.trim, R.split(':', f)) + return {name, label: label ? label : name} + }, R.split(',', fields)) + }) + return R.compose(evolve, merge)(params) +} diff --git a/app/javascript/legacy/recurring_donations/edit/index.es6 b/app/javascript/legacy/recurring_donations/edit/index.es6 new file mode 100644 index 00000000..06f54116 --- /dev/null +++ b/app/javascript/legacy/recurring_donations/edit/index.es6 @@ -0,0 +1,384 @@ +// License: LGPL-3.0-or-later +// npm +const flyd = require('flyd') +const mergeAll = require('flyd/module/mergeall') +const flatMap = require('flyd/module/flatmap') +const lift = require('flyd/module/lift') +const snabbdom = require('snabbdom') +const h = require('snabbdom/h') +const R = require('ramda') +const render = require('ff-core/render') +const modal = require('ff-core/modal') +const notification = require('ff-core/notification') +const button = require('ff-core/button') +const request = require('../../common/request') +// local +const cardForm = require('../../components/card-form.es6') +const readableInterval = require('../../nonprofits/recurring_donations/readable_interval') +const format = require('../../common/format') +const supporterAddressForm = require('../../components/supporter-address-form.es6') +const changeAmountWizard = require('./change-amount-wizard.es6') + + +function init() { + var state = { + submitPaydate$: flyd.stream() + , confirmCancel$: flyd.stream() + , changeAmount$: flyd.stream() + , error$: flyd.stream() + } + + const rdPath = `/recurring_donations/${app.pageLoadData.recurring_donation.id}` + const rdUpdateAmountPath = `/recurring_donations/${app.pageLoadData.recurring_donation.id}/update_amount` + const token = utils.get_param('t') + state.donate_again_url = app.pageLoadData.miscellaneous_np_info.donate_again_url; + + // Paydate update and cancellation streams + const updatePaydate$ = flatMap(updatePaydate(rdPath), state.submitPaydate$) + const cancellation$ = flatMap(reqCancel(rdPath), state.confirmCancel$) + +state.changeAmountWizard = changeAmountWizard.init( {nonprofit:app.pageLoadData.nonprofit, + recurring_donation: app.pageLoadData.recurring_donation, + supporter: app.pageLoadData.supporter, + custom_amounts: app.pageLoadData.change_amount_suggestions}); + + state.cardForm = cardForm.init({ + card: { + name: app.pageLoadData.supporter.name + , address_zip: app.pageLoadData.supporter.zip_code + } + , path: '/cards' + , payload: { + edit_token: token + , path: rdPath + , card: { holder_id: app.pageLoadData.supporter.id, holder_type: 'Supporter'} + } + }) + + state.addressForm = supporterAddressForm.init({ + supporter: app.pageLoadData.supporter + , path: rdPath + , payload: { edit_token: token } + }) + + // Card update streams + // update the card id on the recurring donation after the card has been saved on CC + // (card-form.es6 component will post the card but will not update the card id on the recurring donation) + state.updateCardID$ = flatMap( + resp => request({ + method: 'put' + , path: rdPath + , send: {edit_token: token, token: resp.token, card_name: resp.name} + }).load + , state.cardForm.saved$ + ) + + + // Stream of notification messages + const message$ = flyd.mergeAll([ + flyd.map(R.always('Paydate successfully updated'), updatePaydate$) + , flyd.map(R.always('Address successfully updated'), state.addressForm.response$) + , flyd.map(R.always('Card successfully updated'), state.updateCardID$) + ]) + state.notification = notification.init({message$}) + + // A bunch of streams that cause the modal to close: + state.modalID$ = flyd.map( + R.always(null) + , mergeAll([ + updatePaydate$ + , state.updateCardID$ + , cancellation$ + , state.addressForm.response$ + ]) + ) + + // Stream of vals that cause loading animation to show/hide + state.loading$ = mergeAll([ + flyd.map(R.always(true), state.submitPaydate$) + , flyd.map(R.always(true), state.confirmCancel$) + , flyd.map(R.always(false), updatePaydate$) + , flyd.map(R.always(false), cancellation$) + ]) + + // Simply replace old recurring donations with new ones based on ajax responses + const setNew = (old, resp) => resp.body.recurring_donation + state.recDon$ = flyd.scanMerge([ + [updatePaydate$, setNew] + , [cancellation$, setNew] + , [state.updateCardID$, setNew] + , [state.updateCardAndAmount$, setNew] + ], app.pageLoadData.recurring_donation) + + return state +} + + +// -- Stream creator functions + +const updatePaydate = path => ev => { + ev.preventDefault() + const paydate = Number(ev.currentTarget.querySelector('input').value) + return request({ + method: 'PUT' + , path: path + , send: {edit_token: utils.get_param('t'), paydate: paydate} + }).load +} + + +const reqCancel = path => ev => { + ev.preventDefault() + return request({ + method: 'delete' + , path: path + , send: {edit_token: utils.get_param('t')} + }).load +} + + +// -- Virtual DOM functions + +function view(state) { + var rd = state.recDon$() + var supporter = state.addressForm.supporter$() + var status = rd.active ? 'Active' : 'Deactivated' + var interval = rd.active ? readableInterval(rd.interval, rd.time_unit) : 'Deactivated' + + return h('div.u-maxWidth--600.u-margin--auto.u-marginTop--50.u-padding--15.js-view-confirm', [ + h('h3.u-centered.u-marginBottom--20', ['Recurring Donation for ', String(supporter.name|| supporter.email)]) + // Show deactivated notification box if deactivated + , rd.active ? '' : h('p.u-centered.pastelBox--orange.u-padding--10.u-marginBottom--20', 'This recurring donation has been deactivated') + // Recurring Donation info table + , h('table.table--striped.u-marginBottom--50', [ + h('tr', [ + h('td.u-strong', 'Created on') + , h('td', format.date.toSimple(rd.created_at)) + ]) + , h('tr', [ + h('td.u-strong', 'Recurring amount') + , h('td', '$' + format.centsToDollars(rd.amount)) + ]) + , h('tr', [ + h('td.u-strong', 'Organization') + , h('td', [h('a', {props: {href: `/nonprofits/${rd.nonprofit_id}`, target: '_blank'}}, String(rd.nonprofit_name))]) + ]) + , h('tr', [ + h('td.u-strong', 'Card') + , h('td', String(rd.card_name)) + ]) + , h('tr', [ + h('td.u-strong', 'Donor email') + , h('td', String(app.pageLoadData.supporter.email)) + ]) + , h('tr', [ + h('td.u-strong', 'Recurring donation status') + , h('td', String(status)) + ]) + , h('tr', [ + h('td.u-strong', 'Recurring interval') + , h('td', String(interval)) + ]) + , rd.active + ? '' + : h('tr', [ + h('td.u-strong', 'Cancelled By') + , h('td', String(rd.cancelled_by)) + ]) + , rd.active + ? '' + : h('tr', [ + h('td.u-strong', 'Cancelled At') + , h('td', format.date.readableWithTime(rd.cancelled_at)) + ]) + , h('tr', [ + h('td.strong', 'Address') + , h('td', [ + h('small', [ + [supporter.address, supporter.city].filter(R.identity).join(', ') + , h('br') + , [supporter.state_code, supporter.zip_code, supporter.country].filter(R.identity).join(', ') + ]) + ]) + ]) + , rd.interval === 1 && rd.time_unit === 'month' + ? h('tr', [ + h('td.u-strong', 'Fixed paydate') + , h('td', String(rd.paydate ? rd.paydate : 'None')) + ]) + : '' + ]) + , actions(state) + , rd.active ? '' : reactivate(rd.nonprofit_id) + , cancelModal(state) + , updateCardModal(state) + , editPaydateModal(state) + , updateAddressModal(state) + , changeAmountModal(state) + , notification.view(state.notification) + ]) +} + + +const reactivate = np_id => + h('p.u-centered', [ h('a.button', {props: {href: `/nonprofits/${np_id}/donate`}}, 'Reactivate') ]) + + +function actions(state) { + var rd = state.recDon$() + if(!rd.active) return '' + var modalID$ = state.modalID$ + return h('div.pastelBox--looseleaf.u-padding--15.u-marginBottom--50', [ + h('p.u-strong.u-centered', 'What would you like to do?') + , h('ul.hasBullets.u-maxWidth--400.u-margin--auto', [ + h('li', [changeAmountBtn(modalID$)]) + , h('li', [updateCardBtn(modalID$)]) + + , h('li', [updateAddressBtn(modalID$)]) + , rd.interval === 1 && rd.time_unit === 'month' + ? h('li', [updatePaydateBtn(modalID$)]) + : '' + , h('li', [giveOneTimeDonationBtn(state)]) + , h('li', [cancelBtn(modalID$)]) + ]) + ]) +} + + +const changeAmountBtn = modalID$ => + h('strong', [ + h('a.test-changeAmount', { + on: {click: [modalID$, 'changeAmountModal']} + }, 'Change my donation amount') + ]) + +const updateCardBtn = modalID$ => + h('strong', [ + h('a.test-updateCard', { + on: {click: [modalID$, 'updateCardModal']} + }, 'Update my card') + ]) + + +const cancelBtn = modalID$ => + h('strong', [ + h('a.test-cancelDonation', { + on: {click: [modalID$, 'cancelRecDonModal']} + }, 'Cancel my recurring donation') + ]) + + +const updatePaydateBtn = modalID$ => + h('strong', [ + h('a', { + on: {click: [modalID$, 'editPaydateModal']} + }, 'Change the day I\'m billed') + ]) + + +const updateAddressBtn = modalID$ => + h('strong', [ + h('a', { + on: {click: [modalID$, 'updateAddressModal']} + }, 'Update my address') + ]) + + +const giveOneTimeDonationBtn = (state) => + h('strong', [ + h('a', { + props:{href: state.donate_again_url} + }, 'Give a one-time donation') + ]) + + +const cancelModal = state => + modal({ + thisID: 'cancelRecDonModal' + , id$: state.modalID$ + , body: h('div.u-marginTop--30.u-centered', [ + h('p.u-marginBottom--20', 'Cancelling your recurring donation will prevent any future charges for this donation.') + , h('hr.diamonds.u-marginBottom--40') + , h('p.u-strong', state.recDon$().nonprofit_name + ' will miss your support!') + , h('hr.diamonds') + , h('div.u-marginTop--30', [confirmCancelBtn(state)]) + ]) + }) + + +const updateCardModal = state => + modal({ + thisID: 'updateCardModal' + , id$: state.modalID$ + , title: 'Update Card' + , body: cardForm.view(state.cardForm) + }) + +const changeAmountModal = state => + modal({ + thisID: 'changeAmountModal' + , id$: state.modalID$ + , title: 'Change Amount' + , body: changeAmountWizard.view(state.changeAmountWizard) + }) + + +const editPaydateModal = state => + modal({ + thisID: 'editPaydateModal' + , id$: state.modalID$ + , title: 'Edit Paydate' + , body: paydateForm(state) + }) + + +const updateAddressModal = state => + modal({ + thisID: 'updateAddressModal' + , id$: state.modalID$ + , title: 'Edit your address' + , body: supporterAddressForm.view(state.addressForm) + }) + + +const paydateForm = state => + h('form', { on: {submit: state.submitPaydate$} }, [ + h('p', 'Enter a day of the month (between 1 and 28) when you want to be charged for this donation.') + , h('p', 'This will fix your donations to that date each month for all future payments.') + , h('input.input--small', { + props: { + type: 'number' + , max: 28 + , min: 1 + , name: 'paydate' + , value: state.recDon$().paydate || 1 + } + }) + , h('br') + , button(R.pick(['loading$', 'error$'], state)) + ]) + + +const confirmCancelBtn = state => + h('form', { on: { submit: state.confirmCancel$ } }, [ + button({ + buttonText: 'Cancel My Donation' + , loading$: state.loading$ + , error$: state.error$ + , buttonClass: 'red' + }) + ]) + + +// -- Render to the page + +var container = document.querySelector('#js-main') +const patch = snabbdom.init([ + require('snabbdom/modules/eventlisteners') +, require('snabbdom/modules/class') +, require('snabbdom/modules/props') +, require('snabbdom/modules/style') +]) +var state = init() +render({patch, view, state, container}) + diff --git a/app/javascript/legacy/recurring_donations/edit/page.js b/app/javascript/legacy/recurring_donations/edit/page.js new file mode 100644 index 00000000..0b4291b4 --- /dev/null +++ b/app/javascript/legacy/recurring_donations/edit/page.js @@ -0,0 +1,2 @@ +// License: LGPL-3.0-or-later +require("./index.es6") diff --git a/app/javascript/legacy/recurring_donations/edit/payment-step.es6 b/app/javascript/legacy/recurring_donations/edit/payment-step.es6 new file mode 100644 index 00000000..271c295e --- /dev/null +++ b/app/javascript/legacy/recurring_donations/edit/payment-step.es6 @@ -0,0 +1,105 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const R = require('ramda') +const flyd = require('flyd') +flyd.lift = require('flyd/module/lift') +flyd.flatMap = require('flyd/module/flatmap') +const request = require('../../common/request') +const cardForm = require('./card-form.es6') +const format = require('../../common/format') +const progressBar = require('../../components/progress-bar') + +function init(params$, donation$) { + var state = { params$: params$, donation$: donation$ } + state.rdUpdateAmountPath = `/recurring_donations/${app.pageLoadData.recurring_donation.id}/update_amount` + state.token = utils.get_param('t') + + state.posting = false + + const cardPayload$ = flyd.map(supp => ({card: {holder_id: supp.id, holder_type: 'Supporter'}}), flyd.stream(state.params$().supporter)) + const card$ = flyd.merge( + flyd.stream({}) + , flyd.map(supp => ({name: supp.name, address_zip: supp.zip_code}), flyd.stream(state.params$().supporter))) + + state.cardForm = cardForm.init({path: '/cards', card$, payload$: cardPayload$, outerError$: state.error$}) + state.supporter$ = state.params$().supporter + // // Set the card ID into the donation object when it is saved + const cardToken$ = flyd.map(R.prop('token'), state.cardForm.saved$) + + state.updateCardAndAmount$ = flyd.flatMap( + resp => { + if(state.posting) return flyd.stream() + else state.posting = true + return request({ + method: 'put' + , path: state.rdUpdateAmountPath + , send: {edit_token: state.token, token: cardToken$(), amount: donation$().amount} + }).load} + , cardToken$ + ) + + + state.error$ = flyd.mergeAll([ + , flyd.map(R.always(undefined), state.cardForm.form.submit$) + , state.cardForm.error$ + , flyd.map(resp => "An unknown error occurred. Please try again later", flyd.filter(resp => + { + return resp.body.error || resp.status >= 300 + }, state.updateCardAndAmount$)) + ]) + + + + state.success$ = flyd.filter(resp => { + return !resp.body.error|| resp.status < 300 + }, state.updateCardAndAmount$) + + // Control progress bar + state.progress$ = flyd.scanMerge([ + [state.cardForm.form.validSubmit$, R.always({status: 'Checking card...', percentage: 20, hidden:false})] + , [state.cardForm.saved$, R.always({status: 'Finalizing...', percentage: 100, hidden:false})] + , [state.cardForm.error$, R.always({hidden: true, percentage: 0})] // Hide when an error shows up + , [flyd.filter(R.identity,state.error$), R.always({hidden: true})] // Hide when an error shows up + ], {hidden: true}) + + state.loading$ = flyd.mergeAll([ + flyd.map(R.always(true), state.cardForm.form.validSubmit$) + , flyd.map(R.always(false), state.cardForm.error$) + , flyd.map(R.always(false), state.error$) + , flyd.map(R.always(false), state.success$) + ]) + + + flyd.lift(() => state.posting = false, state.error$) + + flyd.lift((ev) => { + window.location.reload() + }, + state.success$) + + flyd.lift(() => { + console.log(state.error$()) + }, state.error$) + + return state +} + +function view(state) { + var isRecurring = true + var dedic = {} + return h('div.wizard-step.payment-step', [ + h('p.u-fontSize--18 u.marginBottom--0.u-centered', [ + h('span', '$' + format.centsToDollars(state.donation$().amount)) + , h('strong', isRecurring ? ' monthly recurring' : ' one-time ') + ]) + , dedic && (dedic.first_name || dedic.last_name) + ? h('p.u-centered', `In ${dedic.dedication_type || 'honor'} of ${dedic.first_name} ${dedic.last_name}`) + : '' + , h('div.u-marginBottom--10', [ + cardForm.view(R.merge(state.cardForm, {error$: state.error$, hideButton: state.loading$()})) + , progressBar(state.progress$()) + ]) + ]) +} + +module.exports = {view, init} diff --git a/app/javascript/legacy/recurring_donations/index.js b/app/javascript/legacy/recurring_donations/index.js new file mode 100644 index 00000000..dbfae3ae --- /dev/null +++ b/app/javascript/legacy/recurring_donations/index.js @@ -0,0 +1,15 @@ +// License: LGPL-3.0-or-later +var request = require('../common/client') + +appl.def('update_card', function(form_obj) { + appl.is_loading() + + request.put('/recurring_donations/' + form_obj.recurring_donation_id) + .send({stripe_card: form_obj}) + .end(function(err, resp){ + appl.def('loading', false) + if(!resp.ok) return appl.notify('Unable to update card. Please contact us at support@commitchange.com.') + appl.notify('Card Updated!') + }) +}) + diff --git a/app/javascript/legacy/refunds/create.js b/app/javascript/legacy/refunds/create.js new file mode 100644 index 00000000..d19153b8 --- /dev/null +++ b/app/javascript/legacy/refunds/create.js @@ -0,0 +1,69 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const format = require('../common/format') +var format_err = require('../common/format_response_error') +var request = require('../common/super-agent-promise') + +appl.def('ajax_refunds', { + create: function(charge_id, form_obj, node) { + form_obj = formatter(form_obj) + appl.def({ + loading: true, + refunds: { error: '', loading: true } + }) + post_refund(charge_id, form_obj) + .then(function(resp) { + not_loading() + appl.close_modal() + return resp + }) + .then(function(resp) { return resp.body }) + .then(fetch_data_on_success) + .then(display_success_message) + .catch(show_err) + } +}) + +const formatter = R.evolve({ + amount: format.dollarsToCents +}) + +// Re-fetch all the payment data on the page after a refund has been made +function fetch_data_on_success(refund) { + appl.payments.index() + appl.ajax_payment_details.fetch(appl.payment_details.data.id) + return refund +} + +// Display a nice message confirming the amounts of the refund they just made +function display_success_message(refund) { + appl.notify( + "Your refund was successful!" + ) + return refund +} + +// Reset the loading state in the ui +function not_loading(x) { + appl.def({loading: false, refunds: {loading: false}}) + return x +} + +// Display an error in the ui +function show_err(resp) { + not_loading() + console.warn('Error in promise chain: ', resp) + appl.def('refunds', { + error: format_err(resp), + loading: false + }) +} + +// Make the ajax request, returning a Promise +function post_refund(charge_id, obj) { + return request + .post('/nonprofits/' + app.nonprofit_id + '/charges/' + charge_id + '/refunds') + .send({refund: obj}) + .perform() +} + diff --git a/app/javascript/legacy/settings/index/branding/index.js b/app/javascript/legacy/settings/index/branding/index.js new file mode 100644 index 00000000..10ee8272 --- /dev/null +++ b/app/javascript/legacy/settings/index/branding/index.js @@ -0,0 +1,53 @@ +// License: LGPL-3.0-or-later +// npm +const snabbdom = require('snabbdom') +const flyd = require('flyd') +const R = require('ramda') +const render = require('ff-core/render') +flyd.flatMap = require('flyd/module/flatmap') +flyd.filter = require('flyd/module/filter') +flyd.mergeAll = require('flyd/module/mergeall') +const notification = require('ff-core/notification') +// local +const fonts = require('../../../common/brand-fonts') +const request = require('../../../common/request') +const colorPicker = require('../../../components/color-picker.es6') +const view = require('./view') + +function init() { + var np = R.merge(app.nonprofit, {}) + var state = { + nonprofit: np + , font$: flyd.stream({ + key: np.brand_font || 'bitter' + , family: np.brand_font ? fonts[np.brand_font].family : fonts.bitter.family + , name: np.brand_font ? fonts[np.brand_font]['name'] : 'Bitter' + }) + , color: np.brand_color + , submit$: flyd.stream() + , color$: flyd.stream() + } + + const resp$ = flyd.flatMap( + state => flyd.map(R.prop('body'), request({ + method: 'put' + , path: `/nonprofits/${np.id}` + , send: {nonprofit: { brand_color: state.colorPicker.color$(), brand_font: state.font$().key }} + }).load) + , state.submit$) + + var notify$ = flyd.map(()=> 'We successfully saved your branding settings!', resp$) + + state.loading$ = flyd.mergeAll([ + flyd.map(()=> true, state.submit$) + , flyd.map(()=> false, resp$) + ]) + + state.notification = notification.init({message$: notify$}) + state.colorPicker = colorPicker.init(state.nonprofit.brand_color) + + return state +} + +module.exports = {view, init} + diff --git a/app/javascript/legacy/settings/index/branding/view.js b/app/javascript/legacy/settings/index/branding/view.js new file mode 100644 index 00000000..ff177fe2 --- /dev/null +++ b/app/javascript/legacy/settings/index/branding/view.js @@ -0,0 +1,79 @@ +// License: LGPL-3.0-or-later +// npm +const h = require('snabbdom/h') +const R = require('ramda') +const notification = require('ff-core/notification') +const button = require('ff-core/button') +// local +const colorPicker = require('../../../components/color-picker.es6') +const fonts = require('../../../common/brand-fonts') + + +const message = 'This branding will be applied to your donate buttons, profile page, campaign pages and event pages' + +var updatedTask = "Select a color and font to make your fundraising tools consistent with your brand." +const view = state => + h('section.branding.settings-pane.nonprofit-settings.hide', [ + h('header.pane-header', [h('h3', 'Branding')]) + , h('div.pane-inner.branding', [ + h('p.pastelBox--yellow.u-padding--10', message) + , h('br') + , h('div.branding-settings-wrapper', [ + colorPickWrap(state) + , fontPicker(state) + , form(state) + ]) + , preview(state) + ]) + , notification.view(state.notification) + ]) + +const colorPickWrap = state => + h('div.color-wrapper', [ + h('p.title', 'Select Brand Color') + , colorPicker.view(state.colorPicker) + , h('div.colPick-wrapper.inner#colorpicker') + ]) + +const fontPicker = state => + h('div.font-wrapper', [ + h('p.title', 'Select Brand Font') + , fontListing(state) + ]) + +const fontListing = state => + h('ul.inner', R.map(R.apply(fontRow(state)), R.toPairs(fonts))) + +const fontRow = R.curry((state, key, font) => + h('li', { + style: { fontFamily: font.family } + , on: {click: [state.font$, R.merge(font, {key: key})]} + }, font.name) +) + +const form = state => { + var btn = button({ buttonText: 'Save Branding' , loading$: state.loading$ }) + + return h('form.branding-form', { + on: {submit: ev => {ev.preventDefault(); state.submit$(state)}} + }, [btn]) +} + +const preview = state => + h('div.preview-wrapper', [ + h('p.title', 'Preview') + , previewDonateBtn(state) + ]) + +const previewDonateBtn = state => + h('div.branded-donate-button-wrapper', [ + h('p.branded-donate-button', { + style: { + background: state.colorPicker.color$() + , fontFamily: state.font$().family + } + }, 'Donate' ) + ]) + +module.exports = view + diff --git a/app/javascript/legacy/settings/index/email-settings/index.js b/app/javascript/legacy/settings/index/email-settings/index.js new file mode 100644 index 00000000..6dcb4ca2 --- /dev/null +++ b/app/javascript/legacy/settings/index/email-settings/index.js @@ -0,0 +1,45 @@ +// License: LGPL-3.0-or-later +// npm +const snabbdom = require('snabbdom') +const flyd = require('flyd') +const R = require('ramda') +const render = require('ff-core/render') +const notification = require('ff-core/notification') +const serializeForm = require('form-serialize') +flyd.flatMap = require('flyd/module/flatmap') +flyd.mergeAll = require('flyd/module/mergeall') + +// local +const request = require('../../../common/request') +const view = require('./view') + +function init() { + var state = { submit$: flyd.stream() } + + // formSerialize will set checked boxes to "on" and unchecked boxes to "". We want it to be true/false instead + const formObj$ = R.compose( + flyd.map(obj => R.map(val => val === 'on' ? true : false, obj)) + , flyd.map(ev => serializeForm(ev.currentTarget, {hash: true, empty: true})) + )(state.submit$) + + const path = `/nonprofits/${app.nonprofit_id}/users/${app.current_user_id}/email_settings` + + const updateResp$ = flyd.flatMap( + obj => request({ path, method: 'post' , send: {email_settings: obj} }).load + , formObj$ ) + + state.email_settings$ = flyd.map(R.prop('body'), request({method: 'get', path}).load) + + state.loading$ = flyd.mergeAll([ + flyd.map(R.always(true), state.submit$) + , flyd.map(R.always(false), updateResp$) + ]) + + const notify$ = flyd.map(()=> 'Email notification settings updated.', updateResp$) + state.notification = notification.init({message$: notify$}) + + return state +} + +module.exports = {init, view} + diff --git a/app/javascript/legacy/settings/index/email-settings/view.js b/app/javascript/legacy/settings/index/email-settings/view.js new file mode 100644 index 00000000..493dad37 --- /dev/null +++ b/app/javascript/legacy/settings/index/email-settings/view.js @@ -0,0 +1,61 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const button = require('ff-core/button') +const notification = require('ff-core/notification') + +const view = state => { + var settings = state.email_settings$() + if(!settings) { + return h('section.settings-pane.nonprofit-settings.notifications.hide', [h('i.fa.fa-spin.fa-spinner'), ' Loading...']) + } + return h('section.settings-pane.nonprofit-settings.notifications.hide', [ + h('header.pane-header', [h('h3', 'Email Notifications')]) + , h('p', `Choose the emails you want to receive for ${app.user.email}`) + , h('form.notificationsForm', {on: {submit: ev=> {ev.preventDefault(); state.submit$(ev)}}}, [ + h('fieldset', [ + h('input#notifications_payments', {props: {type: 'checkbox', name: 'notify_payments', checked: settings.notify_payments}}) + , h('label', {props: {htmlFor: 'notifications_payments'}}, [ + h('strong', 'General payment notifications') + , h('br') + , h('small', 'Receive donation receipts, ticket receipts, and refund notifications') + ]) + ]) + , h('fieldset', [ + h('input#notifications_campaigns', {props: {type: 'checkbox', name: 'notify_campaigns', checked: settings.notify_campaigns}}) + , h('label', {props: {htmlFor: 'notifications_campaigns'}}, [ + h('strong', 'Campaign notifications') + , h('br') + , h('small', 'Receive all campaign receipts by default (you can also enable/disable these emails within the settings for each campaign page)') + ]) + ]) + , h('fieldset', [ + h('input#notifications_events', {props: {type: 'checkbox', name: 'notify_events', checked: settings.notify_events}}) + , h('label', {props: {htmlFor: 'notifications_events'}}, [ + h('strong', 'Event notifications') + , h('br') + , h('small', 'Receive all event receipts by default (you can also enable/disable these emails within the settings for eachp event page)') + ]) + ]) + , h('fieldset', [ + h('input#notifications_payouts', {props: {type: 'checkbox', name: 'notify_payouts', checked: settings.notify_payouts}}) + , h('label', {props: {htmlFor: 'notifications_payouts'}}, [ + h('strong', 'Payout notifications') + , h('br') + , h('small', 'Receive notifications about pending, succeeded, and/or failed payouts') + ]) + ]) + , h('fieldset', [ + h('input#notifications_recurring_donations', {props: {type: 'checkbox', name: 'notify_recurring_donations', checked: settings.notify_recurring_donations}}) + , h('label', {props: {htmlFor: 'notifications_recurring_donations'}}, [ + h('strong', 'Recurring donation cancellation notifications') + , h('br') + , h('small', 'Receive emails when a donor cancels their recurring donation') + ]) + ]) + , button({loading$: state.loading$}) + , notification.view(state.notification) + ]) + ]) +} + +module.exports = view diff --git a/app/javascript/legacy/settings/index/integrations/index.js b/app/javascript/legacy/settings/index/integrations/index.js new file mode 100644 index 00000000..8ae97776 --- /dev/null +++ b/app/javascript/legacy/settings/index/integrations/index.js @@ -0,0 +1,60 @@ +// License: LGPL-3.0-or-later +const h = require('snabbdom/h') +const R = require('ramda') +const flyd = require('flyd') +const request = require('../../../common/request') +const flyd_lift = require('flyd/module/lift') +const colors = require('../../../common/colors') + +function init() { + var state = { + clickSync$: flyd.stream() + , mailchimpKeyResp$: request({method: 'get', path: `/nonprofits/${app.nonprofit_id}/nonprofit_keys`, query: {select: 'mailchimp_token'}}).load + } + state.mailchimpKey$ = flyd.map(R.prop('response'), flyd.filter(resp => resp.status === 200, state.mailchimpKeyResp$)) + return state +} + + +function view(state) { + return h('section.integrations.settings-pane.nonprofit-settings', { + style: {display: 'none'} + }, [ + h('header.pane-header', [h('h3', 'Integrations')]) + , h('p', 'Connect your CommitChange account with other apps to take advantage of integration features.') + , integrationSection(state, state.mailchimpKey$(), 'MailChimp', mailchimpConnectedMessage, mailchimpNotConnectedMessage) + , h('br') + ]) +} + +// A section fo each integration; pass in the API key for the integration, the +// name, and two functions for bodies: the first body function for when the API +// key is defined, the second body function for when the API key is undefined +const integrationSection = (state, key, name, bodyConnected, bodyNotConnected) => { + return h('div.pane-inner.integrations', [ + h('h6', { + style: {color: key ? colors['$bluegrass'] : colors['$grey']} + }, [ + key ? h('i.fa.fa-check') : h('i.fa.fa-question-circle') + , ' ' + name + ]) + , key ? bodyConnected(state) : bodyNotConnected(state) + ]) +} + +const mailchimpNotConnectedMessage = state => { + return h('p', [ + 'Connect with MailChimp to automatically sync supporter emails to your MailChimp Email Lists.' + , h('br') + , h('a', {props: {href: `/nonprofits/${app.nonprofit_id}/nonprofit_keys/mailchimp_login`}}, 'Click here to connect your Mailchimp account.') + ]) +} +const mailchimpConnectedMessage = state => { + return h('p', [ + 'Congrats! You Mailchimp account has been connected successfully.' + , h('br') + , h('a', {props: {href: `/nonprofits/${app.nonprofit_id}/supporters?show-modal=mailchimpSettingsModal`}}, 'Click here to manage your email list sync settings.') + ]) +} + +module.exports = {view, init} diff --git a/app/javascript/legacy/settings/index/page.js b/app/javascript/legacy/settings/index/page.js new file mode 100644 index 00000000..16358647 --- /dev/null +++ b/app/javascript/legacy/settings/index/page.js @@ -0,0 +1,134 @@ +// License: LGPL-3.0-or-later +var request = require('../../common/client') +require('../../common/image_uploader') +require('../../common/el_swapo') +require('../../common/restful_resource') + +const render = require('ff-core/render') +const h = require('snabbdom/h') +const R = require('ramda') +const flyd = require('flyd') +const snabbdom = require('snabbdom') +const branding = require('./branding/index') +const emailSettings = require('./email-settings/index') +const integrations = require('./integrations/index') + +function init() { + var state = {} + state.emailSettings = emailSettings.init() + state.branding = branding.init() + state.integrations = integrations.init() + return state +} + +function view(state) { + return h('div', [ + emailSettings.view(state.emailSettings) + , branding.view(state.branding) + , integrations.view(state.integrations) + ]) +} + +// -- Render flimflam + +var container = document.querySelector('#js-main') +const patch = snabbdom.init([ + require('snabbdom/modules/eventlisteners') +, require('snabbdom/modules/class') +, require('snabbdom/modules/props') +, require('snabbdom/modules/style') +]) +var state = init() +render({patch, view, state, container}) + + + +// Initialize the froala wysiwyg +appl.def('initialize_froala', function(){ + var editable = require('../../common/editable') + editable($('.editable'), { + email_buttons: true, + placeholder: 'Edit donation receipt message here.', + sticky: true, + noUpdateOnChange: true + }) +}) + +var np_route = '/nonprofits/' + app.nonprofit_id + +appl.def('update_card_failure_message', function() { + appl.def('card_failure_message.loading', true) + var messageTop = document.getElementById('js-messageTop').innerHTML + var messageBottom = document.getElementById('js-messageBottom').innerHTML + var data = { nonprofit: { + card_failure_message_top: messageTop + ,card_failure_message_bottom: messageBottom + } + } + request.put(np_route + '.json').send(data).end(function(resp) { + appl.notify('Card failure email successfully saved') + appl.def('card_failure_message.loading', false) + }) +}) + +appl.def('update_custom_receipt', function(node) { + appl.def('receipt.loading', true) + var classToFind = getClassToFindEditor() + var receipt = appl.prev_elem(node).getElementsByClassName(classToFind)[0].innerHTML + var data = { nonprofit: {thank_you_note: receipt} } + request.put(np_route + '.json').send(data).end(function(resp) { + appl.notify('Receipt successfully saved') + appl.def('receipt.loading', false) + }) +}) + +appl.def('update_change_amount_message', function(node) { + appl.def('receipt.loading', true) + var classToFind = getClassToFindEditor() + var msg = appl.prev_elem(node).getElementsByClassName(classToFind)[0].innerHTML + var data = { miscellaneous_np_info: {change_amount_message: msg} } + request.put(np_route + '/miscellaneous_np_info.json').send(data).end(function(resp) { + appl.notify('Change amount message saved') + appl.def('receipt.loading', false) + }) +}) + +if(app.current_nonprofit_user) { + appl.verify_identity = require('../../nonprofits/payouts/index/verify_identity') + appl.create_bank_account = require('../../bank_accounts/create.es6') +} + +appl.def('statement.validate', function(node) { + var statement_val = appl.prev_elem(node).value + appl.def('statement.name', statement_val) + if(statement_val.search(/[^\w+(<{@?&!$;:\.\-\'\"\,\s}>)]/gi) < 0) { + appl.def('statement.invalid', false) + appl.def('error', '') + } + else { + appl.def('statement.invalid', true) + appl.def('error', 'Statement name cannot contain special characters') + } +}) + +appl.def('cancel_billing_subscription', function() { + appl.notify('Cancelling subscription...') + appl.def('loading', true) + request.put(np_route + '/billing_subscription/cancel') + .send({}).end(function(resp) { + appl.def('loading', false) + }) +}) + +function getClassToFindEditor() +{ + if (app.editor === 'froala' ) + return "froala-element" + else if (app.editor === 'quill') + return "ql-editor" +} + +window.onload = function() { + appl.initialize_froala() +} + diff --git a/app/javascript/legacy/stripe_wrapper/index.es6 b/app/javascript/legacy/stripe_wrapper/index.es6 new file mode 100644 index 00000000..dc9ac2dc --- /dev/null +++ b/app/javascript/legacy/stripe_wrapper/index.es6 @@ -0,0 +1,54 @@ +// License: LGPL-3.0-or-later +const jQuery = require ('jquery') + +/** + * A wrapper for replicating Stripe.js v2's tokenizing features + * with a compatible API. It allows a service provider to use fully free software + * for Stripe integration. Whether that meets your needs is up to you :) + * + * To use it set the `payment_provider.stripe_proprietary_v2_js` to `false` + * (which is the default in settings) + */ +class Stripe { + + setPublishableKey(key) { + + this.card = new TokenizerWrapper( 'card', key) + this.bankAccount = new TokenizerWrapper('bank_account',key) + } +} + +class TokenizerWrapper { + constructor( inner_field_name, key) + { + this.inner_field_name = inner_field_name + this.key = key + } + + createToken(outer_obj, callback) { + var self = this + var auth = 'Bearer '+ self.key + + + var inner_field_name = self.inner_field_name + + var obj = {} + + obj[inner_field_name] = outer_obj + + jQuery.ajax('https://api.stripe.com/v1/tokens', { + headers: { + 'Authorization': auth, + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded'}, + method: 'POST', + data: obj + }).done((data, textStatus, jqXHR) => { + callback(jqXHR.status, data) + }).fail((jqXHR, textStatus, errorThrown) => { + callback(jqXHR.status, jqXHR.responseJSON) + }) + } +} + +global.Stripe = new Stripe() \ No newline at end of file diff --git a/app/javascript/legacy/stripe_wrapper/page.js b/app/javascript/legacy/stripe_wrapper/page.js new file mode 100644 index 00000000..0b4291b4 --- /dev/null +++ b/app/javascript/legacy/stripe_wrapper/page.js @@ -0,0 +1,2 @@ +// License: LGPL-3.0-or-later +require("./index.es6") diff --git a/app/javascript/legacy/super-admin/fullcontact-table.js b/app/javascript/legacy/super-admin/fullcontact-table.js new file mode 100644 index 00000000..c15f7800 --- /dev/null +++ b/app/javascript/legacy/super-admin/fullcontact-table.js @@ -0,0 +1,11 @@ +// License: LGPL-3.0-or-later +const h = require('flimflam/h') +const searchTable = require('../components/search-table') + +const row = (data) => { + const results = JSON.stringify(data, null, 2) + return h('pre', results) +} + +module.exports = state => searchTable(state, [], row, 'Search by email') + diff --git a/app/javascript/legacy/super-admin/nonprofits-table.js b/app/javascript/legacy/super-admin/nonprofits-table.js new file mode 100644 index 00000000..8efa9c47 --- /dev/null +++ b/app/javascript/legacy/super-admin/nonprofits-table.js @@ -0,0 +1,67 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const h = require('flimflam/h') +const searchTable = require('../components/search-table') + +const header = [ + h('tr', [ + h('th', '') + , h('th.pl-0', 'Info') + , h('th.sm-hide', 'Processed') + , h('th.sm-hide', 'Links') + ]) +] + +const link = (href, text) => h('p.m-0', [ h('a', {props: {href, target: '_blank'}}, text)]) + +const npoLinkCurry = id => (path, text) => link(`/nonprofits/${id}/${path}`, text ? text : path) + +const links = (npoLink, data) => + h('div', [ + npoLink('payments') + , npoLink('supporters') + , npoLink('settings') + , npoLink('campaigns', 'campaigns: ' + data.campaigns_count) + , npoLink('events', 'events: ' + data.events_count) + , link('https://dashboard.stripe.com/search?query=' + data.stripe_account_id, 'Stripe account') + , data.stripe_customer_id + ? link('https://dashboard.stripe.com/search?query=' + data.stripe_customer_id, 'Stripe customer') + : '' + ]) + +const processed = data => + h('div', [ + h('p.m-0.bold', data.total_processed || '$0.00') + , h('p.m-0.bold.color-green', data.total_fees || '$0.00') + , h('p.m-0', (100 * data.percentage_fee).toFixed(1) + '%') + ]) + +const row = (data={}, i) => { + const npoLink = npoLinkCurry(data.id) + return h('tr.sub', [ + h('td.content-width.color-grey', ++i + '.') + , h('td.pl-0', [ + h('h5.m-0.max-width-1', [npoLink('', + data.name + ' (' + data.state_code + ')')]) + , h('p.m-0', '#' + data.id) + , h('p.m-0', data.email || '') + , h('p.m-0', data.created_at) + , h('p.m-0.color-red', [ + h('span', {class: { 'color-green' : data.vetted }} + , data.vetted ? 'vetted' : 'not vetted') + , h('span.color-grey.mx-1', ' | ') + , h('span', {class: { 'color-green' : data.verification_status === 'verified' }} + , data.verification_status || '') + ]) + , h('div.md-hide.lg-hide', [ + processed(data) + , links(npoLink, data) + ]) + ]) + , h('td.sm-hide', [processed(data)]) + , h('td.sm-hide', [links(npoLink, data)]) + ]) +} + +module.exports = state => searchTable(state, header, row, 'Search NPOs') + diff --git a/app/javascript/legacy/super-admin/page.js b/app/javascript/legacy/super-admin/page.js new file mode 100644 index 00000000..a3491c7c --- /dev/null +++ b/app/javascript/legacy/super-admin/page.js @@ -0,0 +1,48 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const h = require('flimflam/h') +const flyd = require('flimflam/flyd') +const render = require('flimflam/render') +const tabswap = require('flimflam/ui/tabswap') + +const nposTable = require('./nonprofits-table') +const profilesTable = require('./profiles-table') +const fullContactTable = require('./fullcontact-table') +const topNav = require('../components/top-nav') +const searchData = require('../common/search-data') + +const init = () => { + const activeTab$ = flyd.stream(0) + const pageLength = 30 + const nposData = searchData('admin/search-nonprofits', pageLength) + const profilesData = searchData('admin/search-profiles', pageLength) + const fullContactData = searchData('admin/search-fullcontact', pageLength) + + return { + activeTab$ + , nposData + , profilesData + , fullContactData + } +} + +const view = state => + h('div', [ + topNav('Super Admin') + , h('div.container.pt-3', [ + tabswap.labels({ names: ['NPOs', 'Profiles', 'FC'], active$: state.activeTab$}) + ]) + , h('div.container.px-2.pb-3', [ + tabswap.content({ sections: [ + [nposTable(state.nposData)] + , [profilesTable(state.profilesData)] + , [fullContactTable(state.fullContactData)] + ] + , active$: state.activeTab$}) + ]) + ]) + +const container = document.getElementById('ff-render-super-admin') + +render(view, init(), container) + diff --git a/app/javascript/legacy/super-admin/profiles-table.js b/app/javascript/legacy/super-admin/profiles-table.js new file mode 100644 index 00000000..040d60c4 --- /dev/null +++ b/app/javascript/legacy/super-admin/profiles-table.js @@ -0,0 +1,48 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const h = require('flimflam/h') +const searchTable = require('../components/search-table') +const request = require('../common/client') + +const link = (href, text) => h('p.m-0', [ h('a', {props: {href, target: '_blank'}}, text)]) + + + +const row = (data={}, i) => { + const sendUserConfirmation = (user_id) => + { + request.get(`/admin/resend_user_confirmation`).query({profile_id: data.id}).end((err, result) => + { + if (err) + { + window.alert(`Uh oh, we have a bug! Error is in browser console (Ctrl-Shift-i) and listed next: ${err}`) + console.error(err) + } + else { + + window.alert("Confirmation sent!") + } + + + }); + }; + + const name = data.name ? data.name : 'No name' + return h('tr.sub', [ + h('td.content-width.color-grey', ++i + '.') + , h('td.pl-0', [ + h('h5.m-0.max-width-1', [link(`/profiles/${data.id}/`, name)]) + , h('p.m-0', '#' + data.id) + , data.email ? h('p.m-0', data.email) : '' + , data.city ? h('p.m-0', data.city) : '' + , data.created_at + , h('p.m-0', {class: { + 'color-green' : data.is_confirmed + , 'color-red' : !data.is_confirmed }} + , data.is_confirmed ? 'confirmed' : [h('a', {on: {click: () => {sendUserConfirmation(data.id)}}}, 'unconfirmed')]) + ]) + ]) +} + +module.exports = state => searchTable(state, [], row, 'Search profiles') + diff --git a/app/javascript/legacy/supporters/index.js b/app/javascript/legacy/supporters/index.js new file mode 100644 index 00000000..faaa173d --- /dev/null +++ b/app/javascript/legacy/supporters/index.js @@ -0,0 +1,67 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const h = require('snabbdom/h') +const flyd = require('flyd') +const render = require('ff-core/render') +const request = require('../common/request') +const snabbdom = require('snabbdom') +const flyd_lift = require('flyd/module/lift') +const flyd_mergeAll = require('flyd/module/mergeall') +const url = require('url') + +// TODO move this into sub-component in the future +const mailchimpModal = require('./settings/mailchimp-integration-settings') + +// This is the root component for the supporters dashboard/CRM, found on /nonprofits/:nonprofit_id/supporters + +function init() { + var state = { } + var thisUrl = url.parse(location.href, true) + const mailchimpSyncClick$ = getMailchimpClickSync() + const mailchimpKeyResp$ = request({method: 'get', path: `/nonprofits/${app.nonprofit_id}/nonprofit_keys`, query: {select: 'mailchimp_token'}}).load + const hasKey$ = flyd.filter(resp => resp.status === 200, mailchimpKeyResp$) + const modalID$ = flyd_mergeAll([ + flyd_lift(openModalOrAuth, mailchimpSyncClick$, mailchimpKeyResp$) + , flyd.map(()=> thisUrl.query['show-modal'], hasKey$) + ]) + state.mailchimpModal = mailchimpModal.init(modalID$) + return state +} + +// Either return the modal ID to open, or redirect the page to the mailchimp oauth screen +const openModalOrAuth = (ev, resp) => { + if(resp.status === 200) { + return 'mailchimpSettingsModal' + } else { + window.location.href = `/nonprofits/${app.nonprofit_id}/nonprofit_keys/mailchimp_login` + return null + } +} + +const getMailchimpClickSync = () => { + const s = flyd.stream() + document.querySelector('.js-openMailchimpModal') + .addEventListener('click', ev => {appl.close_modal(); s(ev)}) + return s +} + + +function view(state) { + return h('div', [ + mailchimpModal.view(state.mailchimpModal) + ]) +} + + +// -- Render to the page + +var container = document.querySelector('#js-main') +const patch = snabbdom.init([ + require('snabbdom/modules/eventlisteners') +, require('snabbdom/modules/class') +, require('snabbdom/modules/props') +, require('snabbdom/modules/style') +]) +var state = init() +render({patch, view, state, container}) + diff --git a/app/javascript/legacy/supporters/info-card.es6 b/app/javascript/legacy/supporters/info-card.es6 new file mode 100644 index 00000000..b263e762 --- /dev/null +++ b/app/javascript/legacy/supporters/info-card.es6 @@ -0,0 +1,111 @@ +// License: LGPL-3.0-or-later +const request = require("../common/super-agent-frp") +const view = require("vvvview") +const flyd = require('flyd') +const flatMap = require('flyd/module/flatmap') +const scanMerge = require('flyd/module/scanmerge') +const h = require('virtual-dom/h') +const Im = require('immutable') +const Map = Im.Map +const fromJS = Im.fromJS +const OrderedMap = Im.OrderedMap +const format = require('../common/format') + +var state = fromJS({is_visible: false, data: {}, coords: {top: 0, right: 0, left: 0}}) + +var $showClicks = flyd.stream() +var $hideClicks = flyd.stream() + +const root = state => { + if(state.get('is_visible') && state.get('data')) { + return h('aside.infoCard', { + style: { + display: state.get('is_visible') ? 'block' : 'none', + top: state.getIn(['coords', 'top']) - state.get('rows') * 23 + 'px', + left: state.getIn(['coords', 'left']) + 'px', + right: state.getIn(['coords', 'right']) + 'px' + } + }, [ + h('i.fa.fa-times', {onclick: $hideClicks}), + supporterTable(state.get('data')), + h('a.button--micro', {href: state.getIn(['data', 'link'])}, 'View Full Details') + ]) + } else return h('span') +} + +const supporterDetail = pair => { + var [key, val] = pair + if(key === 'link') return '' + return val ? h('tr', [h('td', format.snake_to_words(key)), h('td', val)]) : '' +} + +const supporterTable = supporter => + h('table', supporter.entrySeq().map(supporterDetail).toJS()) + +const displayCard = (state, node) => { + var clientTop = document.documentElement.clientTop + var clientLeft = document.documentElement.clientLeft + var box = node.getBoundingClientRect() + var top = box.top + window.pageYOffset - clientTop + var left = box.left + window.pageXOffset - clientLeft + + // Place card 15px from right when it's too far over. + if(left + 350 >= document.body.offsetWidth) { + state = state.setIn(['coords', 'right'], 15) + state = state.setIn(['coords', 'left'], 'initial') + } else { + state = state.setIn(['coords', 'left'], left) + state = state.setIn(['coords', 'right'], 'initial') + } + + return state + .setIn(['coords', 'top'], top) + .set('is_visible', true) + .set('data', false) +} + +// Count the number of rows of data the supporter has +const calculateRows = state => + state.set('rows', state.get('data').entrySeq().filter(pair => { + var [key, val] = pair + return val && String(val).length + }).count() + 1.5) + +const ajaxSupporter = node => { + var id = node.getAttribute('data-id') + return request.get(`/nonprofits/${app.nonprofit_id}/supporters/${id}/info_card`).perform() +} + +var $responses = flatMap(ajaxSupporter, $showClicks) + +const setSupporterData = (state, response) => { + var d = response.body + if(!d) return state + state = state.set('data', OrderedMap({ + name: d.name + , email: d.email + , phone: utils.pretty_phone(d.phone) + , address: utils.address_with_commas(d.address, d.city, d.state_code, d.zip_code,d.country) + , organization: d.organization + , total_raised: '$' + utils.cents_to_dollars(d.raised) + , link: `/nonprofits/${app.nonprofit_id}/supporters?sid=${d.id}/` + })) + // Count the rows of present data to calculate the card height + state = calculateRows(state) + return state +} + +var $state = flyd.immediate(scanMerge([ + [$hideClicks, state => state.set('is_visible', false)], + [$showClicks, displayCard], + [$responses, setSupporterData], +], state)) + +var infoCard = view(root, document.body, state) + +flyd.map(infoCard, $state) + + +// XXX viewscript lol +appl.def('show_supporter_info_card', function(node){ $showClicks(appl.prev_elem(node)) }) + diff --git a/app/javascript/legacy/supporters/settings/mailchimp-integration-settings.js b/app/javascript/legacy/supporters/settings/mailchimp-integration-settings.js new file mode 100644 index 00000000..7fe3781a --- /dev/null +++ b/app/javascript/legacy/supporters/settings/mailchimp-integration-settings.js @@ -0,0 +1,86 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const h = require('snabbdom/h') +const flyd = require('flyd') +const modal = require('ff-core/modal') +const button = require('ff-core/button') +const request = require('../../common/request') +const serialize = require('form-serialize') +const notification = require('ff-core/notification') +const flyd_flatMap = require('flyd/module/flatmap') +const flyd_mergeAll = require('flyd/module/mergeall') + +function init(modalID$) { + const pathPrefix = `/nonprofits/${app.nonprofit_id}` + var state = { + submitForm$: flyd.stream() + , tagMasters$: flyd.map(R.prop('body'), request({method: 'get', path: pathPrefix + '/tag_masters'}).load) + } + + const emailLists$ = flyd.map(R.prop('body'), request({method: 'get', path: pathPrefix + '/email_lists'}).load) + state.selectedTagMasterIds$ = flyd.map(R.map(ls => ls.tag_master_id), emailLists$) + + const response$ = flyd_flatMap( + form => request({ + method: 'post' + , path: `/nonprofits/${app.nonprofit_id}/email_lists` + , send: {tag_masters: serialize(form, {hash: true})} + }).load + , state.submitForm$ ) + + state.loading$ = flyd_mergeAll([ + flyd.map(()=> false, response$) + , flyd.map(()=> true, state.submitForm$) + ]) + + state.modalID$ = flyd_mergeAll([ + modalID$ + , flyd.map(()=> null, response$) + ]) + + const message$ = flyd_mergeAll([ + flyd.map(()=> 'Tags successfully synced! Your email lists should show on MailChimp within 5-10 minutes', response$) + ]) + state.notification = notification.init({message$}) + + return state +} + + +function view(state) { + var body = h('form', {on: {submit: ev => {ev.preventDefault(); state.submitForm$(ev.currentTarget)}}}, [ + h('p', "You're connected on Mailchimp. Choose the tags that you want to keep in sync with your Mailchimp Email Lists.") + , h('hr') + , h('div.fields', + R.map( + tm => h('fieldset', [ + h('input', { + props: { + type: 'checkbox' + , name: tm.name + , value: tm.id + , id: `mailchimpCheckbox--${tm.id}` + , checked: (state.selectedTagMasterIds$()||[]).indexOf(tm.id) !== -1 + } + }) + , h('label', {props: {htmlFor: `mailchimpCheckbox--${tm.id}`}}, tm.name) + ]) + , (state.tagMasters$() || {data: []}).data ) + ) + , h('hr') + , h('div.u-centered', [ + button({loading$: state.loading$}) + ]) + ]) + return h('div', [ + modal({ + thisID: 'mailchimpSettingsModal' + , id$: state.modalID$ + , title: 'MailChimp Sync' + , body + }) + , notification.view(state.notification) + ]) +} + +module.exports = {view, init} diff --git a/app/javascript/legacy/ticket_levels/get_totals.js b/app/javascript/legacy/ticket_levels/get_totals.js new file mode 100644 index 00000000..0685062e --- /dev/null +++ b/app/javascript/legacy/ticket_levels/get_totals.js @@ -0,0 +1,11 @@ +// License: LGPL-3.0-or-later +// Retrieve the total attendee (ticket) counts for every ticket level for a given event +var request = require("../common/super-agent-promise") + +module.exports = get_totals + +function get_totals(nonprofit_id, event_id) { + return request.get('/nonprofits/' + nonprofit_id + '/events/' + event_id + '/ticket_levels') + .perform() +} + diff --git a/app/javascript/legacy/ticket_levels/manage.js b/app/javascript/legacy/ticket_levels/manage.js new file mode 100644 index 00000000..8588aa0c --- /dev/null +++ b/app/javascript/legacy/ticket_levels/manage.js @@ -0,0 +1,82 @@ +// License: LGPL-3.0-or-later +var request = require('../common/client') +var path = '/nonprofits/' + app.nonprofit_id + '/events/' + appl.event_id + '/ticket_levels' +const reorder = require('../components/drag-to-reorder') + +reorder(`${path}/update_order`, 'js-reorderTickets') + +module.exports = index_ticket_levels + +appl.def('ticket_levels', { + show_create_or_edit: function(action, i){ + var reset = {name: '', amount: 0, limit: '', id: '', description: ''} + appl.def('ticket_levels', { + currently_editing: action === 'edit' ? appl.ticket_levels.data[i] : reset, + current_action: action + }) + appl.open_modal('ticketLevelCreateOrEditModal') + }, + create_or_edit: function(form_obj){ + appl.is_loading() + if(appl.ticket_levels.current_action === 'edit') + edit_ticket_level(form_obj) + else + create_ticket_level(form_obj) + }, + delete: function(id){ + request.del(path + '/' + id).end(function(err, resp){ + after_ticket_level_ajax(err, 'delete') + }) + } +}) + + +function index_ticket_levels(path, cb){ + appl.is_loading() + request.get(path).end(function(err, resp) { + appl.def('ticket_levels.data', resp.body.data.map(augment_ticket_level_data)) + if(cb){cb()} + appl.not_loading() + }) + + function augment_ticket_level_data(data) { + if (data.amount === 0) + data.formatted_amount = 'Free' + else + data.formatted_amount = '$' + appl.cents_to_dollars(data.amount) + if (data.limit) + data.remaining = data.limit - data.quantity + if (data.remaining <= 0) + data.sold_out = true + return data + } +} + + + +function edit_ticket_level(form_obj) { + request.put(path + '/' + form_obj.id, form_obj).end(function(err, resp){ + after_ticket_level_ajax(err, 'update') + }) +} + + +function create_ticket_level(form_obj) { + request.post(path, form_obj).end(function(err, resp){ + after_ticket_level_ajax(err, 'create') + }) +} + + +function after_ticket_level_ajax(err, action) { + appl.not_loading() + if(err) + appl.notify("Sorry, we weren't able to " + action + " your ticket. Please try again in a moment.") + else { + appl.notify('Ticket level succesfully ' + action + 'd.') + index_ticket_levels(path) + appl.open_modal('manageTicketLevelsModal') + } +} + +index_ticket_levels(path) diff --git a/app/javascript/legacy/tickets/index/delete-ticket.js b/app/javascript/legacy/tickets/index/delete-ticket.js new file mode 100644 index 00000000..6dcc6943 --- /dev/null +++ b/app/javascript/legacy/tickets/index/delete-ticket.js @@ -0,0 +1,32 @@ +// License: LGPL-3.0-or-later +const R = require('ramda') +const flyd = require('flyd') +flyd.flatMap = require('flyd/module/flatmap') +const request = require('../../common/request') +const confirmation = require('../../common/confirmation') + +const stream = flyd.stream() + +var table = document.querySelector('.js-table') +table.addEventListener('click', ev=> { + if(ev.target.hasAttribute('data-remove-ticket')) { + confirmation('Are you sure you want to remove this attendee?', + () => stream(ev.target.getAttribute('data-ticket-id')) + ) + } +}) + +const pathPrefix = `/nonprofits/${app.nonprofit_id}/events/${appl.event_id}/tickets/` + +const response = flyd.flatMap( + ticketID => flyd.map(R.prop('body'), request({method: 'delete', path: pathPrefix + ticketID})).load +, stream ) + +// XXX remove viewscript here +flyd.map( + res => { + appl.notify('Successfully removed that attendee') + appl.tickets.index() + } +, response ) + diff --git a/app/javascript/legacy/tickets/index/page.js b/app/javascript/legacy/tickets/index/page.js new file mode 100644 index 00000000..a3ab00dd --- /dev/null +++ b/app/javascript/legacy/tickets/index/page.js @@ -0,0 +1,195 @@ +// License: LGPL-3.0-or-later +require('../../common/restful_resource') +require('../new') +var create_card = require('../../cards/create') +var create_donation = require('../../donations/create') +var request = require('../../common/super-agent-promise') +var get_ticket_levels = require('../../ticket_levels/get_totals') +var format_err = require('../../common/format_response_error') +var format = require('../../common/format') +var confirmation = require('../../common/confirmation') +appl.def('is_usa', format.geography.isUS) +require('../../common/restful_resource') +require('../../components/tables/filtering/apply_filter')('tickets') +require('./delete-ticket') + +function metricsFetch() { + appl.def('loading_metrics', true) + request.get('/nonprofits/' + app.nonprofit_id + '/events/' + appl.event_id + '/metrics') + .perform() + .then(function(resp) { + appl + .def('loading_metrics', false) + .def('metrics', resp.body) + }) + appl.def('loading_ticket_levels', true) + get_ticket_levels(app.nonprofit_id, appl.event_id) + .then(function(resp) { + appl.def('loading_ticket_levels', false) + }) +} + +function fetch(query) { + query = query || {page: 1} + query.page = query.page || 1 + appl.def('loading_tickets', true) + return request.get('/nonprofits/' + app.nonprofit_id + '/events/' + appl.event_id + '/tickets') + .query(query) + .perform() + .then(function(resp) { + appl.def('loading_tickets', false) + if(query.page > 1) appl.concat('tickets.data', resp.body.data) + else appl.def('tickets', resp.body) + }) +} + + +appl.ticket_wiz.on_complete = function(tickets) { + fetch() + metricsFetch() +} + +appl.def('donations.path_prefix', '/') + +appl.def('tickets.index', function() { + appl.def('appl.tickets.query.page', appl.tickets.query.page || 1) + return fetch(appl.tickets.query) +}) + +appl.def('ajax_donations', { + create: function(form_obj, node) { + appl.def('loading', true) + appl.ajax.create('donations', form_obj, node) + .then(appl.not_loading) + .then(function(resp) { + fetch() + appl.close_modal() + appl.notify("Charge successful") + document.querySelector('.newDonationModal-form').reset() + }) + } +}) + + +appl.def('after_create_card', function(resp) { + fetch() + appl.notify("Card successfully saved!") + location.reload() +}) + +appl.def('tickets', { + path_prefix: '/nonprofits/' + app.nonprofit_id + '/events/' + appl.event_id + '/', + query: {page: 1}, + concat_data: true +}) + +appl.def('toggle_checkin', function(id, name, node) { + var checked = appl.prev_elem(node).checked + var message = name + (checked ? ' checked in.' : ' checked out.') + appl.ajax.update('tickets', id, {checked_in: checked}) + .then(function(){ + appl.notify(message) + metricsFetch() + }) +}) + +appl.def('update_ticket', function(id, name, update_text, form_obj) { + appl.ajax.update('tickets', id, form_obj) + .then(function(){ + appl.notify(name + "'s " + update_text + ' updated.') + }) +}) + +appl.def('show_new_donation', function(supporter_id, supporter_name, supporter_email, card_id, card_name) { + appl.def('selected_supporter', { + id: supporter_id, + name: supporter_name, + email: supporter_email + }) + appl.def('selected_card', { + id: card_id, + name: card_name + }) + appl.open_modal('newDonationModal') +}) + +appl.def('show_new_card', function(supporter_id, supporter_name, supporter_email, ticket_id, event_id) { + appl.def('selected_supporter', { + id: supporter_id, + name: supporter_name, + email: supporter_email + }) + appl.def('selected_ticket', { + id: ticket_id + }) + appl.def('selected_event'), { + id: event_id + } + appl.open_modal('newCardModal') +}) + + +// Create a new donation on behalf of a selected supporter and their card +appl.def('create_donation', function(el) { + appl.def('error', '') + appl.def('loading', true) + create_donation(appl.new_donation) + .then(function() { + return fetch() + }) + .then(appl.not_loading) + .then(appl.close_modal) + .then(function() { + appl.prev_elem(el).reset() + appl.notify('Donation successfully made! Receipts have been sent via email.') + location.reload() + }) + .catch(display_err('new_donation_form')) +}) + + +// Create a new card on behalf of a selected supporter +appl.def('create_card', function(card_obj, el) { + appl.def('new_card_form.error', '') + appl.def('loading', true) + create_card({type: 'Supporter', id: appl.selected_supporter.id, email: appl.selected_supporter.email}, card_obj, {event_id: appl.event_id}) + .then(function(card) { + appl.prev_elem(el).reset() + appl.notify("Card successfully saved for " + appl.selected_supporter.name) + return appl.ajax.update('tickets', appl.selected_ticket.id, {token: card.token}) + }) + .then(function() { + return fetch() + }) + .then(appl.not_loading) + .then(appl.close_modal) + .then(() => location.reload()) + .catch(display_err('new_card_form')) +}) + +function display_err(scope) { + return function(resp) { + appl.def('loading', false) + appl.def('error', format_err(resp)) + } +} + +appl.def('remove_card', function(ticket_id, elm) { + var result = confirmation('Are you sure?') + result.confirmed = function() { + appl.is_loading() + + request.post('/nonprofits/' + app.nonprofit_id + '/events/' + appl.event_id + '/tickets/' + ticket_id + '/delete_card_for_ticket') + .send({event_id: appl.event_id, ticket_id:ticket_id}) + .perform() + .then(function(resp) { + appl.not_loading() + appl.notify('Successfully deleted card') + appl.tickets.index() + }) + } +}) + + +fetch() +metricsFetch() diff --git a/app/javascript/legacy/tickets/new.js b/app/javascript/legacy/tickets/new.js new file mode 100644 index 00000000..b4b90f82 --- /dev/null +++ b/app/javascript/legacy/tickets/new.js @@ -0,0 +1,32 @@ +// License: LGPL-3.0-or-later +var path = '/nonprofits/' + app.nonprofit_id + '/events/' + appl.event_id + '/ticket_levels' +var indexTicketLevels = require('../ticket_levels/manage') +var formSerialize = require('form-serialize') +var request = require('../common/super-agent-promise') + +require('../components/wizard') +require('./wizard') + +appl.def('show_new_tickets', function(){ + // indexes ticket levels before showing the new ticket modal + // so that ticket level quantites are up-to-date. + // indexTicketLevels takes the path and a callback + indexTicketLevels(path, show_new_modal) +}) + +appl.def('add_ticket_note', function(n) { + var data = formSerialize(appl.prev_elem(n), {hash: true}) + appl.def('loading', true) + request.put('/nonprofits/' + app.nonprofit_id + '/events/' + app.event_id + '/tickets/' + appl.created_ticket_id + '/add_note') + .send({ticket: data}) + .perform() + .then(function(resp) { + appl.def('loading', false) + appl.close_modal() + }) +}) + +function show_new_modal(){ + appl.open_modal('newTicketModal') +} + diff --git a/app/javascript/legacy/tickets/wizard.js b/app/javascript/legacy/tickets/wizard.js new file mode 100644 index 00000000..ddbc2957 --- /dev/null +++ b/app/javascript/legacy/tickets/wizard.js @@ -0,0 +1,150 @@ +// License: LGPL-3.0-or-later +if(app.autocomplete) { + require('../components/address-autocomplete') +} +require('../cards/create') +var request = require('../common/super-agent-promise') +var create_card = require('../cards/create') +var format_err = require('../common/format_response_error') +var path = '/nonprofits/' + app.nonprofit_id + '/events/' + appl.event_id + '/tickets' + +appl.def('ticket_wiz', { + + // Placeholder for a callback that is evaluated after the tickets are redeemed + on_complete: function() {}, + + // Set all the wizard's default data + set_defaults: function() { + appl.def('ticket_wiz.post_data', { + nonprofit_id: app.nonprofit_id, + tickets: [], + kind: "", + supporter_id: "", + }) + }, + + + // Set/process all the ticket data after submitting the "Tickets" step form + set_tickets: function(form_obj) { + hide_err() + var tickets = [] + var total_amount = 0 + var total_quantity = 0 + for(var key in form_obj.tickets) { + var ticket = form_obj.tickets[key] + ticket.quantity = Number(ticket.quantity) + ticket.amount = Number(ticket.amount) + total_quantity += ticket.quantity + total_amount += ticket.quantity * ticket.amount + if(ticket.quantity > 0) tickets.push({ticket_level_id: ticket.ticket_level_id, quantity: ticket.quantity}) + } + appl.def('ticket_wiz.post_data.tickets', tickets) + + // Calculate total quantity and total charge amount + appl.def('ticket_wiz', { + total_amount: total_amount, + total_quantity: total_quantity + }) + + if(total_amount === 0) { + appl.def('ticket_wiz.post_data.kind', 'free') + } else { + appl.def('ticket_wiz.post_data.kind', 'charge') + } + + if(total_quantity > 0) { + appl.wizard.advance('ticket_wiz') + } else { + appl.notify('Please choose at least one ticket.') + } + }, + + + check_if_any_ticket_levels: function(i, name, node) { + var ticket_level_remainder = appl.ticket_levels.data[i].remaining + var value = appl.prev_elem(node).value + if(value >= ticket_level_remainder) { + appl.notify("There are only " + ticket_level_remainder + " tickets remaining for '" + + name + "'.") + appl.prev_elem(node).value = ticket_level_remainder + } + }, + + + save_supporter: function(form_obj) { + appl.ticket_wiz.save_supporter_promise = request + .post('/nonprofits/' + app.nonprofit_id + '/supporters') + .send({supporter: form_obj}).perform() + .then(function(res) { + appl.ticket_wiz.supporter = res.body + appl.ticket_wiz.post_data.supporter_id = res.body.id + return res.body + }) + .catch(show_err) + appl.wizard.advance('ticket_wiz') + }, + + set_kind: function(node) { + // Tickets creations have a kind of free, offsite, or charge + // OffsitePayments have a kind of check or cash + // We need to save each separately + var op_kind = appl.prev_elem(node).value + var ticket_kind = appl.prev_elem(node).getAttribute('data-ticket-kind') + appl.def('ticket_wiz.post_data.kind', ticket_kind) + appl.def('ticket_wiz.post_data.offsite_payment.kind', op_kind) + }, + + send_payment: function(form_obj) { + appl.def('loading', true) + return appl.ticket_wiz.save_supporter_promise + .then(function(supporter) { + return create_card({type: 'Supporter', id: supporter.id, email: supporter.email}, form_obj) + }) + .catch(show_err) + .then(function(card) { + appl.ticket_wiz.post_data.token = card.token + }) + .then(appl.ticket_wiz.create_tickets) + }, + + create_tickets: function() { + appl.def('loading', true) + return request.post(path) + .send(appl.ticket_wiz.post_data).perform() + .then(complete_wizard) + .then(appl.ticket_wiz.on_complete) + .catch(show_err) + }, + +}) // end appl.def('ticket_wiz'... + + +// To be called when either a free or purchased ticket was successfully +// redeemed; will show a success/thank-you modal +function complete_wizard(resp) { + appl.def('created_ticket_id', resp.body.tickets[0].id) + appl.def('loading', false) + appl.open_modal('confirmTicketsModal') + appl.ticket_wiz.set_defaults() + appl.wizard.reset("ticket_wiz") + hide_err() +} + + +// Display an error on the ticket wizard +// Works on the amount step, supporter step, and free ticket confirmation step. +// The card form step is a special case, it needs some extra state to be set +function show_err(resp) { + appl.def('loading', false) + appl.def('error', format_err(resp)) + appl.def('card_form', {error: true, status: format_err(resp), loading: false, progress_width: '0%'}) +} + +// Hide any errors in the wizard +function hide_err() { + appl.def('loading', false) + appl.def('error', '') + appl.def('card_form', {status: '', error: false, loading: false}) +} + +appl.ticket_wiz.set_defaults() diff --git a/app/javascript/legacy/widget/donate-button.v2.js b/app/javascript/legacy/widget/donate-button.v2.js new file mode 100644 index 00000000..b7d24b2d --- /dev/null +++ b/app/javascript/legacy/widget/donate-button.v2.js @@ -0,0 +1,237 @@ +// License: LGPL-3.0-or-later +/* this file expects a config/config.json that contains +{ "button":{ + "url":"https://commitchange.com", + "css":"https://s3-us-west-1.amazonaws.com/commitchange/manual/donate-button.v2.css" + } +} + + this file is generated by rails when compiling the assets +*/ + + +function on_ios11() { + var userAgent = window.navigator.userAgent; + var has11 = userAgent.search("OS 11_\\d") > 0 + var hasMacOS = userAgent.search(" like Mac OS X") > 0 + + return has11 && hasMacOS; +} + +window.commitchange = { + iframes: [] +, modalIframe: null +} + +commitchange.getParamsFromUrl = (whitelist) => { + var result = {}, + tmp = []; + var items = location.search.substr(1).split("&"); + for (var index = 0; index < items.length; index++) { + tmp = items[index].split("="); + if (whitelist.indexOf(tmp[0])) result[tmp[0]] = decodeURIComponent(tmp[1]); + } + return result; +} + +commitchange.openDonationModal = (iframe, overlay) => { + return (event) => { + overlay.className = 'commitchange-overlay commitchange-open' + iframe.className = 'commitchange-iframe commitchange-open' + if (on_ios11()) { + iframe.style.position = 'absolute' + } + commitchange.setParams(commitchange.getParamsFromButton(event.currentTarget), iframe) + if (on_ios11()) { + iframe.scrollIntoView() + } + + commitchange.open_iframe = iframe + commitchange.open_overlay = overlay + } +} + +// Dynamically set the params of the appended iframe donate window +commitchange.setParams = (params, iframe) => { + params.command = 'setDonationParams' + params.sender = 'commitchange' + iframe.contentWindow.postMessage(JSON.stringify(params), fullHost) +} + +commitchange.hideDonation = () => { + if(!commitchange.open_overlay || !commitchange.open_iframe) return + commitchange.open_overlay.className = 'commitchange-overlay commitchange-closed' + commitchange.open_iframe.className = 'commitchange-iframe commitchange-closed' + if (on_ios11()) { + commitchange.open_iframe.style.position = 'fixed' + } + commitchange.open_overlay = undefined + commitchange.open_iframe = undefined +} + +const fullHost = 'REPLACE_FULL_HOST' + +commitchange.overlay = () => { + let div = document.createElement('div') + div.setAttribute('class', 'commitchange-closed commitchange-overlay') + return div +} + +commitchange.createIframe = (source) => { + let i = document.createElement('iframe') + const url = document.location.href + i.setAttribute('class', 'commitchange-closed commitchange-iframe') + i.src = source + "&origin=" + url + return i +} + +// Given a button with a bunch of data parameters +// return an object of key/vals corresponing to each param +commitchange.getParamsFromButton = (elem) => { + let options = { + offsite: 't' + , type: elem.getAttribute('data-type') + , custom_amounts: elem.getAttribute('data-custom-amounts') || elem.getAttribute('data-amounts') + , amount: elem.getAttribute('data-amount') + , minimal: elem.getAttribute('data-minimal') + , weekly: elem.getAttribute('data-weekly') + , default: elem.getAttribute('data-default') + , custom_fields: elem.getAttribute('data-custom-fields') + , campaign_id: elem.getAttribute('data-campaign-id') + , gift_option_id: elem.getAttribute('data-gift-option-id') + , redirect: elem.getAttribute('data-redirect') + , designation: elem.getAttribute('data-designation') + , multiple_designations: elem.getAttribute('data-multiple-designations') + , hide_dedication: elem.getAttribute('data-hide-dedication')? true : false + , designations_prompt: elem.getAttribute('data-designations-prompt') + , single_amount: elem.getAttribute('data-single-amount') + , designation_desc: elem.getAttribute('data-designation-desc') || elem.getAttribute('data-description') + , locale: elem.getAttribute('data-locale') + , "utm_source": elem.getAttribute('data-utm_source') + , "utm_campaign": elem.getAttribute('data-utm_campaign') + , "utm_medium": elem.getAttribute('data-utm_medium') + , "utm_content": elem.getAttribute('data-utm_content') + , "first_name": elem.getAttribute('data-first_name') + , "last_name": elem.getAttribute('data-last_name') + , "country": elem.getAttribute('data-country') + , "postal_code": elem.getAttribute('data-postal_code') + + + } + // Remove false values from the options + for(let key in options) { + if(!options[key]) delete options[key] + } + return options +} + +commitchange.appendMarkup = () => { + if(commitchange.alreadyAppended) return + else commitchange.alreadyAppended = true + let script = document.getElementById('commitchange-donation-script') || document.getElementById('commitchange-script') + const nonprofitID = script.getAttribute('data-npo-id') + const baseSource = fullHost + "/nonprofits/" + nonprofitID + "/donate?offsite=t" + let elems = document.querySelectorAll('.commitchange-donate') + + for(let i = 0; i < elems.length; ++i) { + let elem = elems[i] + let source = baseSource + + let optionsButton = commitchange.getParamsFromButton(elem) + let options = commitchange.getParamsFromUrl(["utm_campaign","utm_content","utm_source","utm_medium","first_name","last_name","country","postal_code","address","city"]) + for (var attr in optionsButton) { options[attr] = optionsButton[attr]; } + let params = [] + for(let key in options) { + params.push(key + '=' + options[key]) + } + source += "&" + params.join("&") + + if(elem.hasAttribute('data-embedded')) { + source += '&mode=embedded' + let iframe = commitchange.createIframe(source) + elem.appendChild(iframe) + iframe.setAttribute('class', 'commitchange-iframe-embedded') + commitchange.iframes.push(iframe) + } else { + // Show the CommitChange-branded button if it's not set to custom. + if(!elem.hasAttribute('data-custom') && !elem.hasAttribute('data-custom-button')) { + let btn_iframe = document.createElement('iframe') + let btn_src = fullHost + "/nonprofits/" + nonprofitID + "/btn" + if(elem.hasAttribute('data-fixed')) { btn_src += '?fixed=t' } + btn_iframe.src = btn_src + btn_iframe.className = 'commitchange-btn-iframe' + btn_iframe.setAttribute('scrolling', 'no') + btn_iframe.setAttribute('seamless', 'seamless') + elem.appendChild(btn_iframe) + btn_iframe.onclick = commitchange.openDonationModal(iframe, overlay) + } + // Create the iframe overlay for this button + let modal = document.createElement('div') + modal.className = 'commitchange-modal' + let overlay = commitchange.overlay() + let iframe + if(commitchange.modalIframe) { + iframe = commitchange.modalIframe + } else { + iframe = commitchange.createIframe(source) + commitchange.iframes.push(iframe) + commitchange.modalIframe = iframe + } + modal.appendChild(overlay) + document.body.appendChild(iframe) + elem.parentNode.appendChild(modal) + overlay.onclick = commitchange.hideDonation + elem.onclick = commitchange.openDonationModal(iframe, overlay) + } // end else + } // end for loop +} + +// Load the CSS for the parent page element from our AWS server +commitchange.loadStylesheet = () => { + if(commitchange.alreadyStyled) return + else commitchange.alreadyStyled = true + let stylesheet = document.createElement('link') + stylesheet.href = "REPLACE_CSS_URL" + stylesheet.rel = 'stylesheet' + stylesheet.type = 'text/css' + document.getElementsByTagName('head')[0].appendChild(stylesheet) +} + + +// Handle iframe post messages +if(window.addEventListener) { + window.addEventListener('message', (e) => { + // Close the modal + if(e.data === 'commitchange:close') { + commitchange.hideDonation() + } + // Redirect on donation completion using the redirect param + else if(e.data.match(/^commitchange:redirect/)) { + const matches = e.data.match(/^commitchange:redirect:(.+)$/) + if(matches.length === 2) window.location.href = matches[1] + } + }) +} + +// Make initialization calls on document load +if(document.addEventListener) { + document.addEventListener("DOMContentLoaded", (event) => { + commitchange.loadStylesheet() + commitchange.appendMarkup() + }) +} else if(window.jQuery) { + window.jQuery(document).ready(() => { + commitchange.loadStylesheet() + commitchange.appendMarkup() + }) +} else { + window.onload = () => { + commitchange.loadStylesheet() + commitchange.appendMarkup() + } +} + +if(document.querySelector('.commitchange-donate')) { + commitchange.loadStylesheet() + commitchange.appendMarkup() +} diff --git a/app/javascript/legacy/wip.txt b/app/javascript/legacy/wip.txt new file mode 100644 index 00000000..465d618b --- /dev/null +++ b/app/javascript/legacy/wip.txt @@ -0,0 +1,27 @@ +./bank_accounts/confirm/page.js +./nonprofits/btn/page.js +./nonprofits/button/page.js +./nonprofits/donate/page.js +./nonprofits/supporters/index/page.js +./nonprofits/supporters/new/page.js +./nonprofits/payouts/index/page.js +./nonprofits/dashboard/page.js +./nonprofits/supporter_form/page.js +./nonprofits/edit/page.js +./nonprofits/cards/edit/page.js +./nonprofits/recurring_donations/index/page.js +./nonprofits/payments/index/page.js +./nonprofits/show/page.js +./settings/index/page.js +./events/stats/page.js +./events/index/page.js +./events/show/page.js +./page.js +./recurring_donations/edit/page.js +./super-admin/page.js +./tickets/index/page.js +./stripe_wrapper/page.js +./campaigns/supporters/index/page.js +./campaigns/index/page.js +./campaigns/peer_to_peer/page.js +./campaigns/show/page.js \ No newline at end of file diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js deleted file mode 100644 index 7c3021d7..00000000 --- a/app/javascript/packs/application.js +++ /dev/null @@ -1,18 +0,0 @@ -/* eslint no-console:0 */ -// This file is automatically compiled by Webpack, along with any other files -// present in this directory. You're encouraged to place your actual application logic in -// a relevant structure within app/javascript and only use these pack files to reference -// that code so it'll be compiled. -// -// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate -// layout file, like app/views/layouts/application.html.erb - - -// Uncomment to copy all static images under ../images to the output folder and reference -// them with the image_pack_tag helper in views (e.g <%= image_pack_tag 'rails.png' %>) -// or the `imagePath` JavaScript helper below. -// -// const images = require.context('../images', true) -// const imagePath = (name) => images(name, true) - -console.log('Hello World from Webpacker') diff --git a/app/javascript/packs/create_new_offsite_payment_pane.js b/app/javascript/packs/create_new_offsite_payment_pane.js new file mode 100644 index 00000000..868f93d3 --- /dev/null +++ b/app/javascript/packs/create_new_offsite_payment_pane.js @@ -0,0 +1,3 @@ +// License: LGPL-3.0-or-later +// require a root component here. This will be treated as the root of a webpack package +require('../../../javascripts/app/create_new_offsite_payment_pane') \ No newline at end of file diff --git a/app/javascript/packs/donate-button.v2.js b/app/javascript/packs/donate-button.v2.js index 31e25233..83d5dfe6 100644 --- a/app/javascript/packs/donate-button.v2.js +++ b/app/javascript/packs/donate-button.v2.js @@ -1 +1 @@ -require('../donate-button/donate-button.v2.js') \ No newline at end of file +require('../donate-button/donate-button.v2') \ No newline at end of file diff --git a/app/javascript/packs/edit_payment_pane.js b/app/javascript/packs/edit_payment_pane.js new file mode 100644 index 00000000..6413bad9 --- /dev/null +++ b/app/javascript/packs/edit_payment_pane.js @@ -0,0 +1,3 @@ +// License: LGPL-3.0-or-later +// require a root component here. This will be treated as the root of a webpack package +require('../../../javascripts/app/edit_payment_pane') \ No newline at end of file diff --git a/app/javascript/packs/loading_indicator.ts b/app/javascript/packs/loading_indicator.ts new file mode 100644 index 00000000..c4f2bfcf --- /dev/null +++ b/app/javascript/packs/loading_indicator.ts @@ -0,0 +1,8 @@ +// License: LGPL-3.0-or-later +require('../../../javascripts/app/loading_indicator') + + + + + + diff --git a/app/javascript/packs/page__.js b/app/javascript/packs/page__.js new file mode 100644 index 00000000..10da02c3 --- /dev/null +++ b/app/javascript/packs/page__.js @@ -0,0 +1 @@ +require('../legacy/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__bank_accounts__confirm.js b/app/javascript/packs/page__bank_accounts__confirm.js new file mode 100644 index 00000000..98a85309 --- /dev/null +++ b/app/javascript/packs/page__bank_accounts__confirm.js @@ -0,0 +1 @@ +require('../legacy/bank_accounts/confirm/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__campaigns__index.js b/app/javascript/packs/page__campaigns__index.js new file mode 100644 index 00000000..372a5f2a --- /dev/null +++ b/app/javascript/packs/page__campaigns__index.js @@ -0,0 +1 @@ +require('../legacy/campaigns/index/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__campaigns__peer_to_peer.js b/app/javascript/packs/page__campaigns__peer_to_peer.js new file mode 100644 index 00000000..4a393e8d --- /dev/null +++ b/app/javascript/packs/page__campaigns__peer_to_peer.js @@ -0,0 +1 @@ +require('../legacy/campaigns/peer_to_peer/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__campaigns__show.js b/app/javascript/packs/page__campaigns__show.js new file mode 100644 index 00000000..000293df --- /dev/null +++ b/app/javascript/packs/page__campaigns__show.js @@ -0,0 +1 @@ +require('../legacy/campaigns/show/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__campaigns__supporters__index.js b/app/javascript/packs/page__campaigns__supporters__index.js new file mode 100644 index 00000000..78cd75de --- /dev/null +++ b/app/javascript/packs/page__campaigns__supporters__index.js @@ -0,0 +1 @@ +require('../legacy/campaigns/supporters/index/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__events__index.js b/app/javascript/packs/page__events__index.js new file mode 100644 index 00000000..b1157e54 --- /dev/null +++ b/app/javascript/packs/page__events__index.js @@ -0,0 +1 @@ +require('../legacy/events/index/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__events__show.js b/app/javascript/packs/page__events__show.js new file mode 100644 index 00000000..c5979939 --- /dev/null +++ b/app/javascript/packs/page__events__show.js @@ -0,0 +1 @@ +require('../legacy/events/show/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__events__stats.js b/app/javascript/packs/page__events__stats.js new file mode 100644 index 00000000..506435f6 --- /dev/null +++ b/app/javascript/packs/page__events__stats.js @@ -0,0 +1 @@ +require('../legacy/events/stats/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__nonprofits__btn.js b/app/javascript/packs/page__nonprofits__btn.js new file mode 100644 index 00000000..dfd0d65e --- /dev/null +++ b/app/javascript/packs/page__nonprofits__btn.js @@ -0,0 +1 @@ +require('../legacy/nonprofits/btn/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__nonprofits__button.js b/app/javascript/packs/page__nonprofits__button.js new file mode 100644 index 00000000..18e55f95 --- /dev/null +++ b/app/javascript/packs/page__nonprofits__button.js @@ -0,0 +1 @@ +require('../legacy/nonprofits/button/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__nonprofits__cards__edit.js b/app/javascript/packs/page__nonprofits__cards__edit.js new file mode 100644 index 00000000..7f52e9c1 --- /dev/null +++ b/app/javascript/packs/page__nonprofits__cards__edit.js @@ -0,0 +1 @@ +require('../legacy/nonprofits/cards/edit/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__nonprofits__dashboard.js b/app/javascript/packs/page__nonprofits__dashboard.js new file mode 100644 index 00000000..0637951e --- /dev/null +++ b/app/javascript/packs/page__nonprofits__dashboard.js @@ -0,0 +1 @@ +require('../legacy/nonprofits/dashboard/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__nonprofits__donate.js b/app/javascript/packs/page__nonprofits__donate.js new file mode 100644 index 00000000..be5a4333 --- /dev/null +++ b/app/javascript/packs/page__nonprofits__donate.js @@ -0,0 +1 @@ +require('../legacy/nonprofits/donate/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__nonprofits__edit.js b/app/javascript/packs/page__nonprofits__edit.js new file mode 100644 index 00000000..b1e51cc1 --- /dev/null +++ b/app/javascript/packs/page__nonprofits__edit.js @@ -0,0 +1 @@ +require('../legacy/nonprofits/edit/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__nonprofits__payments__index.js b/app/javascript/packs/page__nonprofits__payments__index.js new file mode 100644 index 00000000..8d9a7568 --- /dev/null +++ b/app/javascript/packs/page__nonprofits__payments__index.js @@ -0,0 +1 @@ +require('../legacy/nonprofits/payments/index/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__nonprofits__payouts__index.js b/app/javascript/packs/page__nonprofits__payouts__index.js new file mode 100644 index 00000000..286e995c --- /dev/null +++ b/app/javascript/packs/page__nonprofits__payouts__index.js @@ -0,0 +1 @@ +require('../legacy/nonprofits/payouts/index/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__nonprofits__recurring_donations__index.js b/app/javascript/packs/page__nonprofits__recurring_donations__index.js new file mode 100644 index 00000000..21bd60ee --- /dev/null +++ b/app/javascript/packs/page__nonprofits__recurring_donations__index.js @@ -0,0 +1 @@ +require('../legacy/nonprofits/recurring_donations/index/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__nonprofits__show.js b/app/javascript/packs/page__nonprofits__show.js new file mode 100644 index 00000000..fe6656fc --- /dev/null +++ b/app/javascript/packs/page__nonprofits__show.js @@ -0,0 +1 @@ +require('../legacy/nonprofits/show/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__nonprofits__supporter_form.js b/app/javascript/packs/page__nonprofits__supporter_form.js new file mode 100644 index 00000000..d479a5ad --- /dev/null +++ b/app/javascript/packs/page__nonprofits__supporter_form.js @@ -0,0 +1 @@ +require('../legacy/nonprofits/supporter_form/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__nonprofits__supporters__index.js b/app/javascript/packs/page__nonprofits__supporters__index.js new file mode 100644 index 00000000..a68ed3cb --- /dev/null +++ b/app/javascript/packs/page__nonprofits__supporters__index.js @@ -0,0 +1 @@ +require('../legacy/nonprofits/supporters/index/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__nonprofits__supporters__new.js b/app/javascript/packs/page__nonprofits__supporters__new.js new file mode 100644 index 00000000..75646822 --- /dev/null +++ b/app/javascript/packs/page__nonprofits__supporters__new.js @@ -0,0 +1 @@ +require('../legacy/nonprofits/supporters/new/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__recurring_donations__edit.js b/app/javascript/packs/page__recurring_donations__edit.js new file mode 100644 index 00000000..8a8c0a26 --- /dev/null +++ b/app/javascript/packs/page__recurring_donations__edit.js @@ -0,0 +1 @@ +require('../legacy/recurring_donations/edit/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__settings__index.js b/app/javascript/packs/page__settings__index.js new file mode 100644 index 00000000..67694bf8 --- /dev/null +++ b/app/javascript/packs/page__settings__index.js @@ -0,0 +1 @@ +require('../legacy/settings/index/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__stripe_wrapper.js b/app/javascript/packs/page__stripe_wrapper.js new file mode 100644 index 00000000..a27ca2c2 --- /dev/null +++ b/app/javascript/packs/page__stripe_wrapper.js @@ -0,0 +1 @@ +require('../legacy/stripe_wrapper/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__super-admin.js b/app/javascript/packs/page__super-admin.js new file mode 100644 index 00000000..afe2c607 --- /dev/null +++ b/app/javascript/packs/page__super-admin.js @@ -0,0 +1 @@ +require('../legacy/super-admin/page.js') \ No newline at end of file diff --git a/app/javascript/packs/page__tickets__index.js b/app/javascript/packs/page__tickets__index.js new file mode 100644 index 00000000..53f2b897 --- /dev/null +++ b/app/javascript/packs/page__tickets__index.js @@ -0,0 +1 @@ +require('../legacy/tickets/index/page.js') \ No newline at end of file diff --git a/app/javascript/packs/registration_page.js b/app/javascript/packs/registration_page.js new file mode 100644 index 00000000..ed2bb0fd --- /dev/null +++ b/app/javascript/packs/registration_page.js @@ -0,0 +1,4 @@ +// License: LGPL-3.0-or-later + +require('../../../javascripts/app/registration_page') + diff --git a/app/javascript/packs/session_login_page.js b/app/javascript/packs/session_login_page.js new file mode 100644 index 00000000..3ac3acc4 --- /dev/null +++ b/app/javascript/packs/session_login_page.js @@ -0,0 +1,3 @@ +// License: LGPL-3.0-or-later + +require('../../../javascripts/app/session_login_page') \ No newline at end of file diff --git a/app/views/campaigns/index.html.erb b/app/views/campaigns/index.html.erb index f7ec7810..36a608a8 100644 --- a/app/views/campaigns/index.html.erb +++ b/app/views/campaigns/index.html.erb @@ -9,7 +9,7 @@ - <%= IncludeAsset.js '/client/js/campaigns/index/page.js' %> + <%= javascript_packs_with_chunks_tag 'page__', 'page__campaigns__index' %> <% end %> <%= render 'components/header', diff --git a/app/views/layouts/_javascripts.html.erb b/app/views/layouts/_javascripts.html.erb index 6ca0c96c..f86da7d9 100644 --- a/app/views/layouts/_javascripts.html.erb +++ b/app/views/layouts/_javascripts.html.erb @@ -14,8 +14,6 @@ Stripe.setPublishableKey("<%= Settings.payment_provider.stripe_public_key %>"); window._csrf = "<%= form_authenticity_token %>"; -<%= IncludeAsset.js "/client/js/page.js" %> - <%= IncludeAsset.js '/client/js/i18n.js' %>