diff --git a/client/main.js b/client/main.js index c1578c1d6f..af32484411 100644 --- a/client/main.js +++ b/client/main.js @@ -1,12 +1,19 @@ -var mapShareKey = 'map-shares'; +var main = window.main || {}; + +main.mapShareKey = 'map-shares'; + var lastCompleted = typeof lastCompleted !== 'undefined' ? lastCompleted : ''; function getMapShares() { - var alreadyShared = JSON.parse(localStorage.getItem(mapShareKey) || '[]'); + var alreadyShared = JSON.parse( + localStorage.getItem(main.mapShareKey) || + '[]' + ); + if (!alreadyShared || !Array.isArray(alreadyShared)) { - localStorage.setItem(mapShareKey, JSON.stringify([])); + localStorage.setItem(main.mapShareKey, JSON.stringify([])); alreadyShared = []; } return alreadyShared; @@ -23,7 +30,7 @@ function setMapShare(id) { if (!found) { alreadyShared.push(id); } - localStorage.setItem(mapShareKey, JSON.stringify(alreadyShared)); + localStorage.setItem(main.mapShareKey, JSON.stringify(alreadyShared)); return alreadyShared; } diff --git a/common/models/pledge.json b/common/models/pledge.json new file mode 100644 index 0000000000..08e46df414 --- /dev/null +++ b/common/models/pledge.json @@ -0,0 +1,55 @@ +{ + "name": "pledge", + "base": "PersistedModel", + "idInjection": true, + "trackChanges": false, + "properties": { + "nonprofit": { + "type": "string", + "index": true + }, + "amount": { + "type": "number" + }, + "dateStarted": { + "type": "date", + "defaultFn": "now" + }, + "dateEnded": { + "type": "date" + }, + "formerUserId": { + "type": "string" + }, + "isOrphaned": { + "type": "boolean" + }, + "isCompleted": { + "type": "boolean", + "default": "false" + } + }, + "validations": [], + "relations": { + "user": { + "type": "hasMany", + "model": "user", + "foreignKey": "userId" + } + }, + "acls": [ + { + "accessType": "*", + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "DENY" + }, + { + "accessType": "READ", + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "ALLOW" + } + ], + "methods": [] +} diff --git a/common/models/user.json b/common/models/user.json index 776c2c500e..f85cc1023c 100644 --- a/common/models/user.json +++ b/common/models/user.json @@ -166,6 +166,11 @@ "type": "hasMany", "model": "userIdentity", "foreignKey": "" + }, + "pledge": { + "type": "hasOne", + "model": "pledge", + "foreignKey": "" } }, "acls": [ diff --git a/server/boot/certificate.js b/server/boot/certificate.js index b084d9f4b8..400d756bcc 100644 --- a/server/boot/certificate.js +++ b/server/boot/certificate.js @@ -18,6 +18,10 @@ import { fullStackChallangeId } from '../utils/constantStrings.json'; +import { + completeCommitment$ +} from '../utils/commit'; + const debug = debugFactory('freecc:certification'); const sendMessageToNonUser = ifNoUserSend( 'must be logged in to complete.' @@ -114,7 +118,20 @@ export default function certificate(app) { completedDate: new Date(), challengeType }); - return saveUser(user); + return saveUser(user) + // If user has commited to nonprofit, + // this will complete his pledge + .flatMap( + user => completeCommitment$(user), + (user, pledgeOrMessage) => { + if (typeof pledgeOrMessage === 'string') { + debug(pledgeOrMessage); + } + // we are only interested in the user object + // so we ignore return from completeCommitment$ + return user; + } + ); } return Observable.just(user); }) diff --git a/server/boot/commit.js b/server/boot/commit.js index a54dc19506..9a48ea5236 100644 --- a/server/boot/commit.js +++ b/server/boot/commit.js @@ -1,15 +1,224 @@ +import _ from 'lodash'; +import { Observable } from 'rx'; +import debugFactory from 'debug'; +import dedent from 'dedent'; + +import nonprofits from '../utils/commit.json'; +import { + commitGoals, + completeCommitment$ +} from '../utils/commit'; + +import { + unDasherize +} from '../utils'; + +import { + observeQuery, + saveInstance +} from '../utils/rx'; + +import { + ifNoUserRedirectTo +} from '../utils/middleware'; + +const sendNonUserToFront = ifNoUserRedirectTo('/'); +const sendNonUserToCommit = ifNoUserRedirectTo( + '/commit', + 'Must be signed in to update commit' +); +const debug = debugFactory('freecc:commit'); + +function findNonprofit(name) { + let nonprofit; + if (name) { + nonprofit = _.find(nonprofits, (nonprofit) => { + return name === nonprofit.name; + }); + } + + nonprofit = nonprofit || nonprofits[0]; + return nonprofit; +} + export default function commit(app) { const router = app.loopback.Router(); + const { Pledge } = app.models; + router.get( '/commit', commitToNonprofit ); + router.get( + '/commit/pledge', + sendNonUserToFront, + pledge + ); + + router.get( + '/commit/directory', + renderDirectory + ); + + router.post( + '/commit/stop-commitment', + sendNonUserToCommit, + stopCommit + ); + + router.post( + '/commit/complete-goal', + sendNonUserToCommit, + completeCommitment + ); + app.use(router); - function commitToNonprofit(req, res) { - res.render('commit/', { - title: 'Commit to a nonprofit. Commit to your goal.' + function commitToNonprofit(req, res, next) { + const { user } = req; + let nonprofitName = unDasherize(req.query.nonprofit); + + debug('looking for nonprofit', nonprofitName); + const nonprofit = findNonprofit(nonprofitName); + + Observable.just(user) + .flatMap(user => { + if (user) { + debug('getting user pledge'); + return observeQuery(user, 'pledge'); + } + return Observable.just(); + }) + .subscribe( + pledge => { + if (pledge) { + debug('found previous pledge'); + req.flash('info', { + msg: dedent` + Looks like you already have a pledge to ${pledge.displayName}. + Hitting commit here will replace your old commitment. + ` + }); + } + res.render( + 'commit/', + Object.assign( + { + title: 'Commit to a nonprofit. Commit to your goal.', + pledge, + frontEndCert: commitGoals.frontEndCert, + fullStackCert: commitGoals.fullStackCert + }, + nonprofit + ) + ); + }, + next + ); + + } + + function pledge(req, res, next) { + const { user } = req; + const { + nonprofit: nonprofitName = 'girl develop it', + amount = '5', + goal = commitGoals.frontEndCert + } = req.query; + + const nonprofit = findNonprofit(nonprofitName); + + observeQuery(user, 'pledge') + .flatMap(oldPledge => { + // create new pledge for user + const pledge = Pledge( + Object.assign( + { + amount, + goal, + userId: user.id + }, + nonprofit + ) + ); + + if (oldPledge) { + debug('user already has pledge, creating a new one'); + // we orphan last pledge since a user only has one pledge at a time + oldPledge.userId = ''; + oldPledge.formerUser = user.id; + oldPledge.endDate = new Date(); + oldPledge.isOrphaned = true; + return saveInstance(oldPledge) + .flatMap(() => { + return saveInstance(pledge); + }); + } + return saveInstance(pledge); + }) + .subscribe( + ({ nonprofit, goal, amount }) => { + req.flash('success', { + msg: dedent` + Congratulations, you have committed to giving + ${nonprofit} $${amount} each month until you have completed + your ${goal}. + ` + }); + res.redirect('/' + user.username); + }, + next + ); + } + + function renderDirectory(req, res) { + res.render('commit/directory', { + title: 'Commit Directory', + nonprofits }); } + + function completeCommitment(req, res, next) { + const { user } = req; + + return completeCommitment$(user) + .subscribe( + msgOrPledge => { + if (typeof msgOrPledge === 'string') { + return res.send(msgOrPledge); + } + return res.send(true); + }, + next + ); + } + + function stopCommit(req, res, next) { + const { user } = req; + + observeQuery(user, 'pledge') + .flatMap(pledge => { + if (!pledge) { + return Observable.just(); + } + + pledge.formerUserId = pledge.userId; + pledge.userId = null; + pledge.isOrphaned = true; + pledge.dateEnded = new Date(); + return saveInstance(pledge); + }) + .subscribe( + pledge => { + let msg = `You have successfully stopped your pledge.`; + if (!pledge) { + msg = `No pledge found for user ${user.username}.`; + } + req.flash('errors', { msg }); + return res.redirect(`/${user.username}`); + }, + next + ); + } } diff --git a/server/boot/user.js b/server/boot/user.js index ae6453f5d8..f266fa6f12 100644 --- a/server/boot/user.js +++ b/server/boot/user.js @@ -164,7 +164,10 @@ module.exports = function(app) { const username = req.params.username.toLowerCase(); const { path } = req; User.findOne( - { where: { username } }, + { + where: { username }, + include: 'pledge' + }, function(err, profileUser) { if (err) { return next(err); @@ -175,6 +178,7 @@ module.exports = function(app) { }); return res.redirect('/'); } + profileUser = profileUser.toJSON(); var cals = profileUser .progressTimestamps @@ -217,7 +221,6 @@ module.exports = function(app) { return (obj.name || '').match(/^Waypoint/i); }); - debug('user is fec', profileUser.isFrontEndCert); res.render('account/show', { title: 'Camper ' + profileUser.username + '\'s portfolio', username: profileUser.username, @@ -227,6 +230,8 @@ module.exports = function(app) { isGithubCool: profileUser.isGithubCool, isLocked: !!profileUser.isLocked, + pledge: profileUser.pledge, + isFrontEndCert: profileUser.isFrontEndCert, isFullStackCert: profileUser.isFullStackCert, isHonest: profileUser.isHonest, diff --git a/server/model-config.json b/server/model-config.json index 508ff05abc..c993d603df 100644 --- a/server/model-config.json +++ b/server/model-config.json @@ -47,6 +47,10 @@ "dataSource": "db", "public": true }, + "pledge": { + "dataSource": "db", + "public": true + }, "user": { "dataSource": "db", "public": true diff --git a/server/utils/commit-goals.json b/server/utils/commit-goals.json new file mode 100644 index 0000000000..d9f20e47ff --- /dev/null +++ b/server/utils/commit-goals.json @@ -0,0 +1,4 @@ +{ + "frontEndCert": "Front End Development Certification", + "fullStackCert": "Full Stack Development Certification" +} diff --git a/server/utils/commit.js b/server/utils/commit.js new file mode 100644 index 0000000000..6eb48d139b --- /dev/null +++ b/server/utils/commit.js @@ -0,0 +1,36 @@ +import dedent from 'dedent'; +import debugFactory from 'debug'; +import { Observable } from 'rx'; + +import commitGoals from './commit-goals.json'; +const debug = debugFactory('freecc:utils/commit'); + +export { commitGoals }; + +export function completeCommitment$(user) { + const { isFrontEndCert, isFullStackCert } = user; + return Observable.fromNodeCallback(user.pledge, user)() + .flatMap(pledge => { + if (!pledge) { + return Observable.just('No pledge found'); + } + + const { goal } = pledge; + + if ( + isFrontEndCert && goal === commitGoals.frontEndCert || + isFullStackCert && goal === commitGoals.fullStackCert + ) { + debug('marking goal complete'); + pledge.isCompleted = true; + pledge.dateEnded = new Date(); + pledge.formerUserId = pledge.userId; + pledge.userId = null; + return Observable.fromNodeCallback(pledge.save, pledge)(); + } + return Observable.just(dedent` + You have not yet reached your goal of completing the ${goal} + Please retry when you have met the requirements. + `); + }); +} diff --git a/server/utils/commit.json b/server/utils/commit.json new file mode 100644 index 0000000000..c0e09277c4 --- /dev/null +++ b/server/utils/commit.json @@ -0,0 +1,18 @@ +[ + { + "name": "girl develop it", + "displayName": "Girl Develop It", + "donateUrl": "https://www.girldevelopit.com/donate", + "description": "Girl Develop It provides in-person classes for women to learn to code.", + "imgAlt": "Girl Develop It participants coding at tables.", + "imgUrl": "http://i.imgur.com/U1CyEuA.jpg" + }, + { + "name": "black girls code", + "displayName": "Black Girls CODE", + "donateUrl": "http://www.blackgirlscode.com/", + "description": "Black Girls CODE is devoted to showing the world that black girls can code, and do so much more.", + "imgAlt": "Girls developing code with instructor", + "imgUrl": "http://i.imgur.com/HBVrdaj.jpg" + } +] diff --git a/server/utils/middleware.js b/server/utils/middleware.js index 3c541cbb11..f9d4919c9b 100644 --- a/server/utils/middleware.js +++ b/server/utils/middleware.js @@ -1,8 +1,14 @@ -export function ifNoUserRedirectTo(url) { +export function ifNoUserRedirectTo(url, message) { return function(req, res, next) { + const { path } = req; if (req.user) { return next(); } + + req.flash('errors', { + msg: message || `You must be signed to go to ${path}` + }); + return res.redirect(url); }; } diff --git a/server/views/account/show.jade b/server/views/account/show.jade index d3737e3160..1392184431 100644 --- a/server/views/account/show.jade +++ b/server/views/account/show.jade @@ -58,6 +58,13 @@ block content h1.flat-top.wrappable= name h1.flat-top.wrappable= location h1.flat-top.text-primary= "[ " + (progressTimestamps.length) + " ]" + if pledge + .spacer + h4 + | This camper has committed to giving $#{pledge.amount} to + a(href='#{pledge.donateUrl}?ref=freecodecamp.com' target='_blank') #{pledge.displayName} + | each month until they have completed their #{pledge.goal}. + .spacer if isFrontEndCert a.btn.btn-primary(href='/' + username + '/front-end-certification') View My Front End Development Certification if isFullStackCert @@ -154,6 +161,10 @@ block content .panel.panel-info .panel-heading.text-center Manage your account .panel-body + .col-xs-12 + a.btn.btn-lg.btn-block.btn-warning.btn-link-social(href='/logout') + span.ion-android-exit + | Sign me out of Free Code Camp .col-xs-12 a.btn.btn-lg.btn-block.btn-primary.btn-link-social(href='mailto:team@freecodecamp.com') span.ion-email @@ -169,9 +180,9 @@ block content span.ion-unlocked | Let other people see all my solutions .col-xs-12 - a.btn.btn-lg.btn-block.btn-warning.btn-link-social(href='/logout') - span.ion-android-exit - | Sign me out of Free Code Camp + a.btn.btn-lg.btn-block.btn-success.btn-link-social(href='/commit') + span.ion-edit + | Edit my pledge .col-xs-12 a.btn.btn-lg.btn-block.btn-danger.btn-link-social.confirm-deletion span.ion-trash-b diff --git a/server/views/commit/directory.jade b/server/views/commit/directory.jade new file mode 100644 index 0000000000..2ba3836f30 --- /dev/null +++ b/server/views/commit/directory.jade @@ -0,0 +1,20 @@ +extends ../layout +block content + .panel.panel-info + .panel-heading.text-center Commit to one of these nonprofits + .panel-body + .row + .col-xs-12.col-sm-10.col-sm-offset-1 + for nonprofit in nonprofits + .col-xs-12.col-sm-6.col-md-4.story-section + .text-center + h2= nonprofit.displayName + img.testimonial-image.img-responsive.img-center(src=nonprofit.imgUrl) + .button-spacer + a.text-center(href='/commit?nonprofit=#{nonprofit.name}') Commmit to #{nonprofit.displayName} + p= nonprofit.description + .spacer + .col-xs-12 + a.btn.btn-lg.btn-block.btn-primary.btn-link-social(href='mailto:team@freecodecamp.com?subject=Supporting%20Nonprofits') + span.ion-email + | Email us about adding your nonprofit here diff --git a/server/views/commit/index.jade b/server/views/commit/index.jade index ba15d37fc1..34c307a39e 100644 --- a/server/views/commit/index.jade +++ b/server/views/commit/index.jade @@ -2,53 +2,70 @@ extends ../layout block content .panel.panel-info .panel-body - h3.text-center Commit to yourself. Commit to a nonprofit. - .col-xs-12.col-sm-6.col-sm-offset-3 - p Are you looking for a burst of motivation? Do you want to help nonprofits before you’re ready to code for them? You can do both by pledging a monthly donation to a nonprofit until you've earned either your Front End or Full Stack Development certificate. Join Commit below or click "maybe later". - .col-xs-12.col-sm-6.col-sm-offset-3 - h4 Step 1: Choose your goal - .radio - label - input(type='radio' id='front-end-development-certificate' name='goal') - | Front End Development Certificate (takes about 400 hours) - .radio - label - input(type='radio' id='full-stack-development-certificate' name='goal') - | Full Stack Development Certificate (takes about 800 hours) - .spacer - h4 Step 2: Choose one of our nonprofits - .row - .col-xs-12.col-sm-6 - a(href="http://i.imgur.com/U1CyEuA.jpg" data-lightbox="img-enlarge") - img.img-responsive(src='http://i.imgur.com/U1CyEuA.jpg' alt="Girl Develop It participants coding at tables.") - .radio - label - input(type='radio' id='girl-develop-it' name='nonprofit') - | Girl Develop It is a nonprofit that provides in-person classes for women to learn to code. - .col-xs-12.col-sm-6 - a(href="http://i.imgur.com/NERytFF.jpg" data-lightbox="img-enlarge") - img.img-responsive(src='http://i.imgur.com/NERytFF.jpg' alt="Vets in Tech participants standing together at a conference.") - .radio - label - input(type='radio' id='vets-in-tech' name='nonprofit') - | Vets in Tech is a nonprofit that helps veterans prepare for tech jobs. - .spacer - h4 Step 3: Choose your monthly pledge - .radio - label - input(type='radio' id='5-dollar-pledge' name='pledge-amount') - | $5 per month - .radio - label - input(type='radio' id='10-dollar-pledge' name='pledge-amount') - | $10 per month - .radio - label - input(type='radio' id='50-dollar-pledge' name='pledge-amount') - | $50 per month + h2.text-center Commit to yourself. Commit to a nonprofit. + .row + .col-xs-12.col-sm-6.col-sm-offset-3 + p Give yourself external motivation and help nonprofits right away. Pledge a monthly donation to a nonprofit until you’ve earned either your Front End or Full Stack Development Certification. + .row + .col-xs-12.col-sm-6.col-sm-offset-3.text-center + h3 Pledge to #{displayName}  + .button-spacer + a(href='#{imgUrl}' data-lightbox='img-enlarge' alt='#{imgAlt}') + img.img-responsive(src='#{imgUrl}' alt='#{imgAlt}') + p.large-p + = description + a(href='/commit/directory') ...or see other nonprofits + .spacer + form.form(name='commit') + .hidden + input(type='text' value='#{name}' name='nonprofit') + .row + .col-xs-12.col-sm-6.col-sm-offset-3 + h3 Choose your goal: + .btn-group.btn-group-justified(data-toggle='buttons' role='group') + label.btn.btn-primary.btn-lg.active + input(type='radio' id=frontEndCert value=frontEndCert name='goal' checked="checked") + | Front End Development Certification (takes about 400 hours) + label.btn.btn-primary.btn-lg + input(type='radio' id=fullStackCert value=fullStackCert name='goal') + | Full Stack Development Certification (takes about 800 hours) .spacer - a.button.btn.btn-block.btn-primary(href='https://www.paypal.com/us/cgi-bin/webscr?cmd=_flow&SESSION=T3x0DY-bLMFXuhmjYZXs-BhmDoiXfuNh5BWad5VBcMomkkDSZY0b_-_W3HS&dispatch=5885d80a13c0db1f8e263663d3faee8d0b9dcb01a9b6dc564e45f62871326a5e') Commit - .button-spacer - a.button.btn.btn-block.btn-warning(href='/') Maybe later + .row + .col-xs-12.col-sm-6.col-sm-offset-3 + h3 Choose your monthly pledge: + .btn-group.btn-group-justified(data-toggle='buttons' role='group') + label.btn.btn-success + input(type='radio' id='5-dollar-pledge' value='5' name='amount') + | $5 per month + label.btn.btn-success.active + input(type='radio' id='10-dollar-pledge' value='10' name='amount' checked="checked") + | $10 per month + label.btn.btn-success + input(type='radio' id='25-dollar-pledge' value='25' name='amount' checked="checked") + | $25 per month + label.btn.btn-success + input(type='radio' id='50-dollar-pledge' value='50' name='amount') + | $50 per month .spacer + .row + .col-xs-12.col-sm-6.col-sm-offset-3.text-center + a#commit-btn-submit.btn.btn-block.btn-lg.signup-btn(href=donateUrl target='_blank') Commit (and open donate page) + + if pledge + form.row(name='stop-pledge' action='/commit/stop-commitment' method='post') + .col-xs-12.col-sm-6.col-sm-offset-3.text-center + .button-spacer + button.btn.btn-block.btn-danger(name='submit' type='submit') Stop my current pledge + else + .row + .col-xs-12.col-sm-6.col-sm-offset-3.text-center + .button-spacer + a.btn.btn-block.btn-default(href='/') Maybe later + .spacer + script. + $(function() { + $('#commit-btn-submit').click(function() { + window.location.href = '/commit/pledge?' + $('form').serialize(); + }); + }); diff --git a/server/views/commit/pledge.jade b/server/views/commit/pledge.jade new file mode 100644 index 0000000000..84ce52ad49 --- /dev/null +++ b/server/views/commit/pledge.jade @@ -0,0 +1,15 @@ +extends ../layout +block content + .panel.panel-info + .panel-body + h3.text-center You've commited! + .row + .col-xs-12.col-sm-6.col-sm-offset-3 + p Congratulations, you have commit to giving + span(style='text-transform: capitalize') #{nonprofit} + | #{amount} dollars a month until you have reached your goal + | of completing your #{goal} + .row + .col-xs-12.col-sm-6.col-sm-offset-3 + img.img-responsive(src='http://i.imgur.com/U1CyEuA.jpg' alt="Girl Develop It participants coding at tables.") + p Girl Develop It is a nonprofit that provides in-person classes for women to learn to code.