client fix

This commit is contained in:
Eric Schultz 2019-11-06 14:36:28 -06:00 committed by Eric Schultz
parent cf7eceee13
commit 87c15f0a0b
307 changed files with 21282 additions and 74 deletions

View file

@ -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()
})

View file

@ -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))
}

View file

@ -0,0 +1,2 @@
// License: LGPL-3.0-or-later
require('./index.es6')

View file

@ -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})
}

View file

@ -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')
}

View file

@ -0,0 +1,4 @@
// License: LGPL-3.0-or-later
if(app.user)
require('../new/wizard')

View file

@ -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)
}
})
}

View file

@ -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)
}
})
}

View file

@ -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', [])
}

View file

@ -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 + "')")
})
}

View file

@ -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)
])
}

View file

@ -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
])
])
}

View file

@ -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}

View file

@ -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` ])
])
}
}

View file

@ -0,0 +1,3 @@
// License: LGPL-3.0-or-later
module.exports = g => g.quantity && (g.quantity - g.total_gifts <= 0)

View file

@ -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 }

View file

@ -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')})

View file

@ -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: 'Youre on your way!',
content: "Once youve written your campaign story and added gift options, you can start sharing it with all your contacts. Were excited for it to succeed!"
}
]
})
if($.cookie('tour_campaign') === String(app.nonprofit_id)) {
$.removeCookie('tour_campaign', {path: '/'})
tour_campaign.init()
tour_campaign.restart()
}

View file

@ -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)

View file

@ -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
}
}

View file

@ -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: $ }

View file

@ -0,0 +1,5 @@
// License: LGPL-3.0-or-later
require('../../timeline')
require('../../totals')
require('./index.es6')

View file

@ -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: $}

View file

@ -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,
}
}

View file

@ -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()

View file

@ -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$)

View file

@ -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 $
}

View file

@ -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()
}
}

View file

@ -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()
})
}

View file

@ -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)
})
}

View file

@ -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)
})

View file

@ -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})
})

View file

@ -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) })
}
})

View file

@ -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'}
}

View file

@ -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('.')))

View file

@ -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

View file

@ -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"
}

View file

@ -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

View file

@ -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
*/

View file

@ -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});`

View file

@ -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

View file

@ -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()
})
})

View file

@ -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')

View file

@ -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 = "<a class='button' target='_blank' href='" + location.origin + "/nonprofits/" + app.nonprofit_id + "/donate' "
if(app.nonprofit && app.nonprofit.brand_color)
donate_button_markup += "style='background-color:" + app.nonprofit.brand_color + ";'>Donate</a>"
else
donate_button_markup += ">Donate</a>"
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 <div>
// and appends all of the <div>s into a <pre> tag
// and then replaces that selected text with the
// newly created <pre> tag
var lines_of_code = this.text().split("\n")
var pre = document.createElement('pre')
pre.className = 'codeText'
// created <div>s for each new line and appends them to <pre>
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 <br>s before and after <pre>
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;

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -0,0 +1,3 @@
// License: LGPL-3.0-or-later
module.exports = /^(?!0\.00)\d{1,3}(,\d{3})*(\.\d\d)?$/

View file

@ -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,}))$/;

View file

@ -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()

View file

@ -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
})

View file

@ -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
}

View file

@ -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()
}

View file

@ -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]*?</script\\s*'
+ '|style\\b' + tagBody + '>[\\s\\S]*?</style\\s*'
// Regular name
+ '|/?[a-z]'
+ tagBody
+ ')>',
'gi')
return html.replace(tagOrComment, '').replace(/</g, '&lt;')
}
format.sql = {}
format.sql.format_sql_array = function(str) {
if(!str) return ''
return format.toSentence(
str.replace(/[""{}]/g,'')
.split(',')
.filter(function(str) {return str !== 'NULL'})
)
}
format.date = {}
format.date.readableWithTime = function(str) {
return moment(str).format("YYYY-MM-DD h:MMa")
}
format.date.toStandard = function(str) {
return moment(str).format("YYYY-MM-DD")
}
format.date.toSimple = function(str) {
if(!str || !str.length) return ''
var d = new Date(str)
return format.zeroPad(d.getMonth() + 1, 2) + '/' +
format.zeroPad(d.getDate(), 2) + '/' +
format.zeroPad(d.getFullYear(), 2)
}
format.geography = {}
format.geography.isUS = function(str) {
return Boolean(str.match(/(^united states( of america)?$)|(^u\.?s\.?a?\.?$)/i))
}

View file

@ -0,0 +1,20 @@
// License: LGPL-3.0-or-later
// This is a little utility to convert a superagent response that has an error
// into a readable single string message
//
// This should work both with 422 unprocessable entities as well as 500 server errors
module.exports = show_err
var err_msg = "We're sorry, but something went wrong. Please try again soon."
function show_err(resp) {
console.error(resp)
if(resp.body && resp.body.error) { return resp.body.error }
if(resp.body && resp.body.errors && resp.body.errors.length) { return resp.body.errors[0] }
if(resp.body) { return resp.body }
if(resp.error) { return resp.error }
return err_msg
}

View file

@ -0,0 +1,16 @@
// License: LGPL-3.0-or-later
const R = require('ramda')
const format = require('../common/format')
require('../common/restful_resource')
appl.def('ajax_metrics', {
index: function() {
appl.ajax.index('metrics').then(function(resp) {
appl.def('metrics.percentage_funded', R.clamp(1,100, format.percent(
resp.body.goal_amount
, resp.body.total_raised
))
)
})
}
})

View file

@ -0,0 +1,15 @@
// License: LGPL-3.0-or-later
var geo = {}
geo.stateCodes = [ 'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'DC', 'FL', 'GA', 'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD', 'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ', 'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'PR', 'RI', 'SC', 'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY' ]
geo.countries = ["(select)","Austria", "Belgium", "Bosnia and Herzegowina", "Bulgaria", "Croatia (Hrvatska)", "Cyprus", "Czech Republic", "Denmark", "Estonia", "Finland", "France", "Germany", "Greece", "Hungary", "Iceland", "India", "Indonesia", "Iran (Islamic Republic of)", "Iraq", "Ireland", "Israel", "Italy", "Jamaica", "Japan", "Jordan", "Kazakhstan", "Kenya", "Kiribati", "Korea, Democratic People's Republic of", "Korea, Republic of", "Kuwait", "Kyrgyzstan", "Lao, People's Democratic Republic", "Latvia", "Lebanon", "Lesotho", "Liberia", "Libyan Arab Jamahiriya", "Liechtenstein", "Lithuania", "Luxembourg", "Macau", "Macedonia, The Former Yugoslav Republic of", "Madagascar", "Malawi", "Malaysia", "Maldives", "Mali", "Malta", "Marshall Islands", "Martinique", "Mauritania", "Mauritius", "Mayotte", "Mexico", "Micronesia, Federated States of", "Moldova, Republic of", "Monaco", "Mongolia", "Montserrat", "Morocco", "Mozambique", "Myanmar", "Namibia", "Nauru", "Nepal", "Netherlands", "Netherlands Antilles", "New Caledonia", "New Zealand", "Nicaragua", "Niger", "Nigeria", "Niue", "Norfolk Island", "Northern Mariana Islands", "Norway", "Oman", "Pakistan", "Palau", "Panama", "Papua New Guinea", "Paraguay", "Peru", "Philippines", "Pitcairn", "Poland", "Portugal", "Puerto Rico", "Qatar", "Reunion", "Romania", "Russian Federation", "Rwanda", "Saint Kitts and Nevis", "Saint Lucia", "Saint Vincent and the Grenadines", "Samoa", "San Marino", "Sao Tome and Principe", "Saudi Arabia", "Senegal", "Seychelles", "Sierra Leone", "Singapore", "Slovakia (Slovak Republic)", "Slovenia", "Solomon Islands", "Somalia", "South Africa", "South Georgia and the South Sandwich Islands", "Spain", "Sri Lanka", "St. Helena", "St. Pierre and Miquelon", "Sudan", "Suriname", "Svalbard and Jan Mayen Islands", "Swaziland", "Sweden", "Switzerland", "Syrian Arab Republic", "Taiwan, Province of China", "Tajikistan", "Tanzania, United Republic of", "Thailand", "Togo", "Tokelau", "Tonga", "Trinidad and Tobago", "Tunisia", "Turkey", "Turkmenistan", "Turks and Caicos Islands", "Tuvalu", "Uganda", "Ukraine", "United Arab Emirates", "United Kingdom", "United States", "United States Minor Outlying Islands", "Uruguay", "Uzbekistan", "Vanuatu", "Venezuela", "Vietnam", "Virgin Islands (British)", "Virgin Islands (U.S.)", "Wallis and Futuna Islands", "Western Sahara", "Yemen", "Yugoslavia", "Zambia", "Zimbabwe"]
geo.countriesWithUSFirst = ["United States", "Afghanistan", "Albania", "Algeria", "American Samoa", "Andorra", "Angola", "Anguilla", "Antarctica", "Antigua and Barbuda", "Argentina", "Armenia", "Aruba", "Australia", "Austria", "Azerbaijan", "Bahamas", "Bahrain", "Bangladesh", "Barbados", "Belarus", "Belgium", "Belize", "Benin", "Bermuda", "Bhutan", "Bolivia", "Bosnia and Herzegowina", "Botswana", "Bouvet Island", "Brazil", "British Indian Ocean Territory", "Brunei Darussalam", "Bulgaria", "Burkina Faso", "Burundi", "Cambodia", "Cameroon", "Canada", "Cape Verde", "Cayman Islands", "Central African Republic", "Chad", "Chile", "China", "Christmas Island", "Cocos (Keeling) Islands", "Colombia", "Comoros", "Congo", "Congo, the Democratic Republic of the", "Cook Islands", "Costa Rica", "Cote d'Ivoire", "Croatia (Hrvatska)", "Cuba", "Cyprus", "Czech Republic", "Denmark", "Djibouti", "Dominica", "Dominican Republic", "East Timor", "Ecuador", "Egypt", "El Salvador", "Equatorial Guinea", "Eritrea", "Estonia", "Ethiopia", "Falkland Islands (Malvinas)", "Faroe Islands", "Fiji", "Finland", "France", "France Metropolitan", "French Guiana", "French Polynesia", "French Southern Territories", "Gabon", "Gambia", "Georgia", "Germany", "Ghana", "Gibraltar", "Greece", "Greenland", "Grenada", "Guadeloupe", "Guam", "Guatemala", "Guinea", "Guinea-Bissau", "Guyana", "Haiti", "Heard and Mc Donald Islands", "Holy See (Vatican City State)", "Honduras", "Hong Kong", "Hungary", "Iceland", "India", "Indonesia", "Iran (Islamic Republic of)", "Iraq", "Ireland", "Israel", "Italy", "Jamaica", "Japan", "Jordan", "Kazakhstan", "Kenya", "Kiribati", "Korea, Democratic People's Republic of", "Korea, Republic of", "Kuwait", "Kyrgyzstan", "Lao, People's Democratic Republic", "Latvia", "Lebanon", "Lesotho", "Liberia", "Libyan Arab Jamahiriya", "Liechtenstein", "Lithuania", "Luxembourg", "Macau", "Macedonia, The Former Yugoslav Republic of", "Madagascar", "Malawi", "Malaysia", "Maldives", "Mali", "Malta", "Marshall Islands", "Martinique", "Mauritania", "Mauritius", "Mayotte", "Mexico", "Micronesia, Federated States of", "Moldova, Republic of", "Monaco", "Mongolia", "Montserrat", "Morocco", "Mozambique", "Myanmar", "Namibia", "Nauru", "Nepal", "Netherlands", "Netherlands Antilles", "New Caledonia", "New Zealand", "Nicaragua", "Niger", "Nigeria", "Niue", "Norfolk Island", "Northern Mariana Islands", "Norway", "Oman", "Pakistan", "Palau", "Panama", "Papua New Guinea", "Paraguay", "Peru", "Philippines", "Pitcairn", "Poland", "Portugal", "Puerto Rico", "Qatar", "Reunion", "Romania", "Russian Federation", "Rwanda", "Saint Kitts and Nevis", "Saint Lucia", "Saint Vincent and the Grenadines", "Samoa", "San Marino", "Sao Tome and Principe", "Saudi Arabia", "Senegal", "Seychelles", "Sierra Leone", "Singapore", "Slovakia (Slovak Republic)", "Slovenia", "Solomon Islands", "Somalia", "South Africa", "South Georgia and the South Sandwich Islands", "Spain", "Sri Lanka", "St. Helena", "St. Pierre and Miquelon", "Sudan", "Suriname", "Svalbard and Jan Mayen Islands", "Swaziland", "Sweden", "Switzerland", "Syrian Arab Republic", "Taiwan, Province of China", "Tajikistan", "Tanzania, United Republic of", "Thailand", "Togo", "Tokelau", "Tonga", "Trinidad and Tobago", "Tunisia", "Turkey", "Turkmenistan", "Turks and Caicos Islands", "Tuvalu", "Uganda", "Ukraine", "United Arab Emirates", "United Kingdom", "United States", "United States Minor Outlying Islands", "Uruguay", "Uzbekistan", "Vanuatu", "Venezuela", "Vietnam", "Virgin Islands (British)", "Virgin Islands (U.S.)", "Wallis and Futuna Islands", "Western Sahara", "Yemen", "Yugoslavia", "Zambia", "Zimbabwe"]
geo.isUSA = function(str) {
return Boolean(str.match(/(^united states( of america)?$)|(^u\.?s\.?a?\.?$)/i))
}
module.exports = geo

View file

@ -0,0 +1,11 @@
// License: LGPL-3.0-or-later
const flyd = require('flimflam/flyd')
const request = require("../common/request")
module.exports = (path, query) => {
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$)
}

View file

@ -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) { })
})

View file

@ -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 = "<i class='fa fa-spin fa-spinner'></i> " + 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
}

View file

@ -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

View file

@ -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 )

View file

@ -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

View file

@ -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)

View file

@ -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
})

View file

@ -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
})
})

View file

@ -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()

View file

@ -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
})

View file

@ -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
})

View file

@ -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)
}

View file

@ -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
}

View file

@ -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

View file

@ -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)
})
}

View file

@ -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}
}

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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
}

3476
app/javascript/legacy/common/vendor/Chart.min.js vendored Executable file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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)
})

View file

@ -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 = '<div class="colpick"><div class="colpick_color"><div class="colpick_color_overlay1"><div class="colpick_color_overlay2"><div class="colpick_selector_outer"><div class="colpick_selector_inner"></div></div></div></div></div><div class="colpick_hue"><div class="colpick_hue_arrs"><div class="colpick_hue_larr"></div><div class="colpick_hue_rarr"></div></div></div><div class="colpick_new_color"></div><div class="colpick_current_color"></div><div class="colpick_hex_field"><div class="colpick_field_letter">#</div><input type="text" maxlength="6" size="6" /></div><div class="colpick_rgb_r colpick_field"><div class="colpick_field_letter">R</div><input type="text" maxlength="3" size="3" /><div class="colpick_field_arrs"><div class="colpick_field_uarr"></div><div class="colpick_field_darr"></div></div></div><div class="colpick_rgb_g colpick_field"><div class="colpick_field_letter">G</div><input type="text" maxlength="3" size="3" /><div class="colpick_field_arrs"><div class="colpick_field_uarr"></div><div class="colpick_field_darr"></div></div></div><div class="colpick_rgb_b colpick_field"><div class="colpick_field_letter">B</div><input type="text" maxlength="3" size="3" /><div class="colpick_field_arrs"><div class="colpick_field_uarr"></div><div class="colpick_field_darr"></div></div></div><div class="colpick_hsb_h colpick_field"><div class="colpick_field_letter">H</div><input type="text" maxlength="3" size="3" /><div class="colpick_field_arrs"><div class="colpick_field_uarr"></div><div class="colpick_field_darr"></div></div></div><div class="colpick_hsb_s colpick_field"><div class="colpick_field_letter">S</div><input type="text" maxlength="3" size="3" /><div class="colpick_field_arrs"><div class="colpick_field_uarr"></div><div class="colpick_field_darr"></div></div></div><div class="colpick_hsb_b colpick_field"><div class="colpick_field_letter">B</div><input type="text" maxlength="3" size="3" /><div class="colpick_field_arrs"><div class="colpick_field_uarr"></div><div class="colpick_field_darr"></div></div></div><div class="colpick_submit"></div></div>',
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<len; i++) {
o.push('0');
}
o.push(hex);
hex = o.join('');
}
return hex;
},
restoreOriginal = function () {
var cal = $(this).parent();
var col = cal.data('colpick').origColor;
cal.data('colpick').color = col;
fillRGBFields(col, cal.get(0));
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));
};
return {
init: function (opt) {
opt = $.extend({}, defaults, opt||{});
//Set color
if (typeof opt.color == 'string') {
opt.color = hexToHsb(opt.color);
} else if (opt.color.r != undefined && opt.color.g != undefined && opt.color.b != undefined) {
opt.color = rgbToHsb(opt.color);
} else if (opt.color.h != undefined && opt.color.s != undefined && opt.color.b != undefined) {
opt.color = fixHSB(opt.color);
} else {
return this;
}
//For each selected DOM element
return this.each(function () {
//If the element does not have an ID
if (!$(this).data('colpickId')) {
var options = $.extend({}, opt);
options.origColor = opt.color;
//Generate and assign a random ID
var id = 'collorpicker_' + parseInt(Math.random() * 1000);
$(this).data('colpickId', id);
//Set the tpl's ID and get the HTML
var cal = $(tpl).attr('id', id);
//Add class according to layout
cal.addClass('colpick_'+options.layout+(options.submit?'':' colpick_'+options.layout+'_ns'));
//Add class if the color scheme is not default
if(options.colorScheme != 'light') {
cal.addClass('colpick_'+options.colorScheme);
}
//Setup submit button
cal.find('div.colpick_submit').html(options.submitText).click(clickSubmit);
//Setup input fields
options.fields = cal.find('input').change(change).blur(blur).focus(focus);
cal.find('div.colpick_field_arrs').mousedown(downIncrement).end().find('div.colpick_current_color').click(restoreOriginal);
//Setup hue selector
options.selector = cal.find('div.colpick_color').on('mousedown touchstart',downSelector);
options.selectorIndic = options.selector.find('div.colpick_selector_outer');
//Store parts of the plugin
options.el = this;
options.hue = cal.find('div.colpick_hue_arrs');
var huebar = options.hue.parent();
//Paint the hue bar
var UA = navigator.userAgent.toLowerCase();
var isIE = navigator.appName === 'Microsoft Internet Explorer';
var IEver = isIE ? parseFloat( UA.match( /msie ([0-9]{1,}[\.0-9]{0,})/ )[1] ) : 0;
var ngIE = ( isIE && IEver < 10 );
var stops = ['#ff0000','#ff0080','#ff00ff','#8000ff','#0000ff','#0080ff','#00ffff','#00ff80','#00ff00','#80ff00','#ffff00','#ff8000','#ff0000'];
if(ngIE) {
var i, div;
for(i=0; i<=11; i++) {
div = $('<div></div>').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
}
});

View file

@ -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);
};
}));

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,3 @@
// License: LGPL-3.0-or-later

View file

@ -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}

View file

@ -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$}

View file

@ -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()
})
})
}

View file

@ -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(''))
}

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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)])
])
}

View file

@ -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}

View file

@ -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}

View file

@ -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)
}

View file

@ -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)
})
}

View file

@ -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
}
})
}

View file

@ -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$)
}
}

Some files were not shown because too many files have changed in this diff Show more