diff --git a/README.md b/README.md index 9e6330e..da6a8e8 100644 --- a/README.md +++ b/README.md @@ -11,5 +11,7 @@ Property | Default | Description `CSOL_DB_PASS` | `null` | Database password. `CSOL_DB_HOST` | `null` | Database host. `CSOL_DB_PORT` | `null` | Database port. +`CSOL_HOST` | `null` | Canonical CSOL host (eg chicagosummeroflearning.org) `COOKIE_SECRET` | `null` | Seed for session cookie. `CSOL_OPENBADGER_URL` | `null` | Openbadger API Location, http://obr.com/v2/ +`CSOL_OPENBADGER_SECRET` | `null` | A shared secret with Open Badger. Should match the OPENBADGER_JWT_SECRET variable on open badger diff --git a/api.js b/api.js index 54e83ef..b4853d9 100644 --- a/api.js +++ b/api.js @@ -85,15 +85,18 @@ function getFullUrl(origin, path) { } // Load data from remote endpoint -function remote (method, path, callback) { - +// TODO - need to add ability to pass data through +// TODO - might want to cache this at some point +function remote (method, path, options, callback) { if (!request[method]) return callback(new errors.NotImplemented('Unknown method ' + method)); + if (_.isFunction(options)) { + callback = options; + options = {}; + } - // TODO - need to add ability to pass data through - // TODO - might want to cache this at some point var endpointUrl = getFullUrl(this.origin, path); - request[method](endpointUrl, function(err, response, body) { + request[method](endpointUrl, options, function(err, response, body) { logger.log('info', 'API request: "%s %s" %s', method.toUpperCase(), endpointUrl, response ? response.statusCode : "Error", err); @@ -101,12 +104,17 @@ function remote (method, path, callback) { if (err) return callback(new errors.Unknown(err)); - if (response.statusCode !== 200) - // TODO - add logging so the upstream error can be debugged - return callback(new (errors.lookup(response.statusCode))()); + if (response.statusCode !== 200) { + var msg; + if (body && body.reason) + msg = body.reason; + return callback(new (errors.lookup(response.statusCode))(msg)); + } try { - var data = JSON.parse(body); + var data = body; + if (!_.isObject(body)) + data = JSON.parse(data); } catch (e) { return callback(new errors.Unknown(e.message)); } @@ -170,10 +178,12 @@ module.exports = function Api(origin, config) { _.each(['get', 'post', 'put', 'patch', 'head', 'del'], function(method) { Object.defineProperty(this, method, { enumerable: true, - value: function(path, callback) { - this.remote(method, path, callback); + value: function(path, opts, callback) { + this.remote(method, path, opts, callback); }, - writable: true // This is needed for mocking + /* TODO: writable is set to true for mocking, but it would + be nice to revisit and try to remove that line. */ + writable: true }); }, this); diff --git a/controllers/auth.js b/controllers/auth.js index 1edb733..de9ba1a 100644 --- a/controllers/auth.js +++ b/controllers/auth.js @@ -2,6 +2,8 @@ var bcrypt = require('bcrypt'); var passwords = require('../lib/passwords'); var usernames = require('../lib/usernames'); var db = require('../db'); +var email = require('../mandrill'); +var logger = require('../logger'); var learners = db.model('Learner'); var guardians = db.model('Guardian'); var signupTokens = db.model('SignupToken'); @@ -9,6 +11,10 @@ var passwordTokens = db.model('PasswordToken'); var COPPA_MAX_AGE = process.env.COPPA_MAX_AGE || 13; var BCRYPT_SEED_ROUNDS = process.env.BCRYPT_SEED_ROUNDS || 10; +var CSOL_HOST = process.env.CSOL_HOST; + +if (!CSOL_HOST) + throw new Error('Must specify CSOL_HOST in the environment'); function validateEmail (email) { // TODO - make sure email is valid @@ -69,13 +75,19 @@ function extractUserData (user) { type: userType, favorites: [], dependents: [], - home: userHome + home: userHome, + underage: user.underage }; } function redirectUser (req, res, user, status) { req.session.user = extractUserData(user); - return res.redirect(status || 303, req.session.user.home); + var target = req.session.user.home; + if (req.session.afterLogin) { + target = req.session.afterLogin; + delete req.session.afterLogin; + } + return res.redirect(status || 303, target); } function clearUser (req, res) { @@ -166,8 +178,6 @@ function processChildLearnerSignup (req, res, next) { }).complete(function(err, token) { if (err || !token) return fail(err); - // TODO - send an email - token.setLearner(user); // Assuming this worked bcrypt.hash(signup.password, BCRYPT_SEED_ROUNDS, function(err, hash) { @@ -175,10 +185,16 @@ function processChildLearnerSignup (req, res, next) { user.updateAttributes({ complete: true, - password: hash + password: hash, + email: normalizedUsername + '@' + CSOL_HOST }).complete(function(err) { if (err) return fail(err); + var confirmationUrl = req.protocol + '://' + req.get('Host') + + '/signup/' + token.token; + email.send('<13 learner signup', { + confirmationUrl: confirmationUrl + }, signup.parent_email); delete req.session.signup; req.flash('modal', { title: 'Welcome to the Chicago Summer of Learning', @@ -240,6 +256,7 @@ function processStandardLearnerSignup (req, res, next) { return fail(err); } + email.send('learner signup', {}, signup.email); delete req.session.signup; redirectUser(req, res, user); }); diff --git a/controllers/backpack.js b/controllers/backpack.js index cf198d2..86d709e 100644 --- a/controllers/backpack.js +++ b/controllers/backpack.js @@ -1,11 +1,37 @@ +const openbadger = require('../openbadger'); + module.exports = function (app) { app.get('/claim', function (req, res, next) { - res.render('claim.html'); - }); + var claimCode = req.query.code; + var user = res.locals.user; + + if (!user) { + req.session.afterLogin = req.originalUrl; + return res.redirect('/login'); + } + + if (!claimCode) + return res.render('claim.html'); + + openbadger.claim({ + code: claimCode.trim(), + email: user.email + }, function(err, data) { + if (err) { + if (err.code === 404 && err.message === 'unknown claim code') + req.flash('error', "That claim code appears to be invalid."); + else if (err.code === 409) + req.flash('warn', "You already have that badge."); + else + req.flash('error', "Unable to claim badge."); + } + else { + req.flash('success', 'Badge claimed!'); + } + return res.redirect('/backpack'); + }); - app.get('/claim/:badgeName', function (req, res, next) { - return res.redirect('/badges/'+req.params.badgeName+'/claim'); }); app.get('/backpack', function (req, res, next) { diff --git a/lib/errors.js b/lib/errors.js index 339f72e..da70c11 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -89,6 +89,7 @@ var BadRequest = createExceptionType('BadRequest', 400); var Unauthorized = createExceptionType('Unauthorized', 401); var Forbidden = createExceptionType('Forbidden', 403); var NotFound = createExceptionType('NotFound', 404); +var Conflict = createExceptionType('Conflict', 409); var Unknown = createExceptionType('Internal', 500); var NotImplemented = createExceptionType('NotImplemented', 501); var BadGateway = createExceptionType('BadGateway', 502); diff --git a/mandrill.js b/mandrill.js index 3dc847d..436bef3 100644 --- a/mandrill.js +++ b/mandrill.js @@ -7,7 +7,7 @@ const FAKE_EMAIL = ('DEBUG' in process.env) var request = require('request'); if (FAKE_EMAIL) { - request = function(opts, cb) { + request.post = function(opts, cb) { logger.log('debug', 'FAKE EMAIL: request.post with opts', opts); cb('EMAIL DISABLED'); }; @@ -18,7 +18,8 @@ const ENDPOINT = process.env['CSOL_MANDRILL_URL'] || const KEY = process.env['CSOL_MANDRILL_KEY']; const TEMPLATES = { - test: 'test' + '<13 learner signup': 'csol-13-signup', + 'learner signup': 'csol-signup' } module.exports = { @@ -26,7 +27,8 @@ module.exports = { /* send(template, context, recipient, callback) - template - internal template name, mapped to mandrill names above + template - internal template name, mapped to mandrill names above, or + mandrill template name context - merge variables (optional) { foo: 'hi' } replaces *|foo|* or *|FOO|* in the template with "hi" @@ -66,7 +68,7 @@ module.exports = { var payload = { key: KEY, - template_name: template, + template_name: TEMPLATES[template] || template, template_content: [], message: { to: recipients, @@ -89,6 +91,21 @@ module.exports = { if (response.statusCode !== 200) return callback(body); + var unsent = []; + _.map(body, function(result) { + var level = 'info'; + if (['sent', 'queued'].indexOf(result.status) === -1) { + level = 'error'; + unsent.push(result); + } + logger.log(level, 'Learner signup email %s for %s', result.status, result.email); + }); + if (unsent.length) + return callback({ + message: 'Some addresses not sent or queued', + results: unsent + }); + return callback(null, body); }); } diff --git a/openbadger.js b/openbadger.js index e3e3f62..0f910e0 100644 --- a/openbadger.js +++ b/openbadger.js @@ -1,10 +1,16 @@ const Api = require('./api'); const errors = require('./lib/errors'); const _ = require('underscore'); +const jwt = require('jwt-simple'); const ENDPOINT = process.env['CSOL_OPENBADGER_URL']; +const JWT_SECRET = process.env['CSOL_OPENBADGER_SECRET']; +const TOKEN_LIFETIME = process.env['CSOL_OPENBADGER_TOKEN_LIFETIME'] || 10000; + if (!ENDPOINT) throw new Error('Must specify CSOL_OPENBADGER_URL in the environment'); +if (!JWT_SECRET) + throw new Error('Must specify CSOL_OPENBADGER_SECRET in the environment'); function normalizeBadge (badge, id) { if (!id) @@ -105,6 +111,24 @@ var openbadger = new Api(ENDPOINT, { orgs: _.values(data.issuers) }); }); + }, + + claim: function claim (query, callback) { + var email = query.email; + var code = query.code; + var claims = { + prn: email, + exp: Date.now() + TOKEN_LIFETIME + }; + var token = jwt.encode(claims, JWT_SECRET); + var params = { + auth: token, + email: email, + code: code, + }; + this.post('/claim', { json: params }, function(err, data) { + return callback(err, data); + }); } }); diff --git a/package.json b/package.json index 459752c..ea00498 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "sequelize": "~1.6.0", "tap": "~0.4.1", "underscore": "~1.4.4", - "winston": "~0.7.1" + "winston": "~0.7.1", + "jwt-simple": "~0.1.0" }, "devDependencies": { "sinon": "~1.7.2", diff --git a/static/media/css/core.css b/static/media/css/core.css index 35afc72..42dde13 100644 --- a/static/media/css/core.css +++ b/static/media/css/core.css @@ -1,7 +1,11 @@ +@import url(http://fonts.googleapis.com/css?family=Open+Sans:400,300); /*general*/ html, body { height: 100%; + font-family: 'Open Sans', sans-serif; + font-weight: 300; + line-height: 18px; } body { margin: 0px; @@ -43,6 +47,10 @@ ol { .navbar .nav { float: none; } +.navbar .nav > li.dropdown.open > .dropdown-toggle { + background-color: inherit; + text-decoration: underline; +} .navbar .nav > li > a { border-left: none; border-right: none; @@ -71,7 +79,7 @@ ol { -webkit-box-shadow: inset 0px -10px 20px 0px rgba(0, 0, 0, 0.25); -moz-box-shadow: inset 0px -10px 20px 0px rgba(0, 0, 0, 0.25); box-shadow: inset 0px -10px 20px 0px rgba(0, 0, 0, 0.25); - padding-top: 1em; + padding-top: 25px; } .navbar .nav-wrap { width: 100%; @@ -145,11 +153,10 @@ ol { color: #c0c0c0; text-decoration: none; } -.footer .lower { - border-top: 1px solid white; - padding-top: 20px; +.footer .upper { + padding: 50px 0 0 0; } -.footer .lower p { +.footer .upper p { background-image: url('../img/csol_logo_sm.png'); padding-left: 209px; background-repeat: no-repeat; @@ -158,12 +165,13 @@ ol { min-height: 131px; margin-bottom: 20px; } -.footer .lower li { +.footer .upper li { border-left: none; border-right: none; } -.footer .upper { - padding: 1em 0; +.footer .lower { + border-top: 1px solid white; + padding: 25px 0 0 0; } .footer ul li { display: inline-block; @@ -199,9 +207,18 @@ ol { padding: 0px 0px 250px 0px; } .wrapper.secondary { + position: relative; color: #fff; height: 250px; - background-image: url('../img/chalkboard_bg.jpg'); + background-image: url('../img/background-chalkboard-green.jpg'); + background-repeat: no-repeat; + -webkit-background-size: cover; + -moz-background-size: cover; + -o-background-size: cover; + background-size: cover; + filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/background-chalkboard-green.jpg', sizingMethod='scale'); + -ms-filter: "progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/background-chalkboard-green.jpg', sizingMethod='scale')"; + overflow: visible; } /*CSOL-site specific*/ .poster a, @@ -248,3 +265,186 @@ input[type="password"].metered:focus:invalid + .password-meter { input[type="password"].metered:focus:invalid:focus + .password-meter { border-color: #e9322d; } +/*landing page specific*/ +body.home { + background-image: url('../img/chalkboard_bg.jpg'); +} +body.home .container { + width: 960px; +} +body.home .navbar .navbar-inner { + background-image: url('../img/spacedimg.jpg'); + background-repeat: no-repeat; + background-position: top center; + -webkit-background-size: cover; + -moz-background-size: cover; + -o-background-size: cover; + background-size: cover; + padding-top: 0px; + filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/spacedimg.png', sizingMethod='scale'); + -ms-filter: "progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/spacedimg.png', sizingMethod='scale')"; + overflow: visible; + max-height: 515px; +} +body.home .navbar .navbar-inner > .container { + background-image: url('../img/home_drawing.png'); + background-position: top center; + background-repeat: no-repeat; +} +body.home .navbar .brand { + background-image: url('../img/csol_logo_sm.png'); + position: absolute; + left: 50%; + margin-left: -102px; + z-index: 2; + top: 50px; + width: 189px; + height: 131px; + padding: 0 0 0 0; +} +body.home .navbar .nav-wrap { + width: inherit; + margin-bottom: 0px; + height: 600px; + width: 310px; + margin: 0 auto; + background: none; + background-image: url('../img/banner300.png'); + background-position: top center; + background-repeat: no-repeat; + position: relative; + z-index: 1; + box-shadow: none; +} +body.home .navbar .nav-wrap ul { + padding-top: 200px; + left: -6px; +} +body.home .navbar .nav-wrap ul > li { + text-align: left; + display: block; + line-height: 9px; +} +body.home .navbar .nav-wrap ul > li > a { + text-transform: inherit; + font-size: 22px; + color: #f4fc00; + text-transform: lowercase; +} +body.home .navbar .nav-wrap ul > li.learn > a:first-letter { + text-transform: capitalize; +} +body.home .navbar .nav-wrap ul > li.log-in > a { + font-size: 18px; + color: #fff; + text-transform: lowercase; + text-align: center; + line-height: 24px; +} +body.home .navbar .nav-wrap ul > li.video { + margin-top: 35px; +} +body.home .navbar .nav-wrap ul > li.video a { + color: #fff; + border-radius: 7px; + margin: auto; + display: block; + width: 140px; + text-align: center; + line-height: 20px; + background: #e82202; + background: -moz-linear-gradient(top, #e82202 0%, #e5381d 44%, #e56854 100%); + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #e82202), color-stop(44%, #e5381d), color-stop(100%, #e56854)); + background: -webkit-linear-gradient(top, #e82202 0%, #e5381d 44%, #e56854 100%); + background: -o-linear-gradient(top, #e82202 0%, #e5381d 44%, #e56854 100%); + background: -ms-linear-gradient(top, #e82202 0%, #e5381d 44%, #e56854 100%); + background: linear-gradient(to bottom, #e82202 0%, #e5381d 44%, #e56854 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#e82202', endColorstr='#e56854', GradientType=0); +} +body.home .navbar .nav-wrap ul > li span { + color: #fff; +} +body.home .navbar .nav-wrap ul > li:nth-child(even) { + border-left: none; + border-right: none; +} +body.home #main { + padding: 0 0 0 0; + display: none; +} +body.home .navbar-static-top .container { + width: inherit; +} +body.home .secondary .upper { + padding-top: 15px; + position: relative; +} +body.home .secondary .upper > p.pull-left { + display: none; +} +body.home .secondary .upper > .pull-right { + float: left; + text-align: center; + width: 340px; + padding-top: 55px; +} +body.home .secondary .lower { + border-top: none; +} +body.home .secondary .lower .pull-right li:first-child { + display: none; +} +body.home .secondary .lower .pull-right li:nth-child(3) { + border-left: 1px solid #fff; + border-right: 1px solid #fff; +} +body.home .secondary .lower .pull-left li:first-child { + border-right: 1px solid #fff; +} +body.home .footer { + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; +} +body.home #rahm { + position: relative; + width: 210px; + padding-left: 100px; + padding-top: 15px; + background-image: url('../img/rahm.png'); + background-repeat: no-repeat; + background-position: center left; + float: left; + -webkit-hyphens: none; + -moz-hyphens: none; + hyphens: none; +} +body.home #bubbles { + float: left; + background-image: url('../img/bubbles.png'); + width: 310px; + height: 140px; + background-repeat: no-repeat; + background-position: 18px 0px; + margin-top: 15px; +} +body.home #bubbles span { + color: #000; + display: block; + width: 100px; +} +body.home #bubbles span.lt { + float: left; + margin-left: 34px; + margin-top: 12px; +} +body.home #bubbles span.lt a { + color: #3B5998; +} +body.home #bubbles span.rt { + margin-top: 52px; + margin-right: 7px; + float: right; + font-size: 24px; + line-height: 20px; +} diff --git a/static/media/css/core.min.css b/static/media/css/core.min.css index c8d0202..8d5e09a 100644 --- a/static/media/css/core.min.css +++ b/static/media/css/core.min.css @@ -1,15 +1,17 @@ -html,body{height:100%;} +@import url(http://fonts.googleapis.com/css?family=Open+Sans:400,300); +html,body{height:100%;font-family:'Open Sans',sans-serif;font-weight:300;line-height:18px;} body{margin:0px;padding:0px;}body .container>br{display:none;} #main{padding-top:45px;padding-bottom:25px;}#main>h1:first-child,#main>h2:first-child,#main>h3:first-child,#main>h4:first-child,#main>p:first-child{margin-top:0px;padding-top:0px;} #main>div>h1:first-child,#main>div>h2:first-child,#main>div>h3:first-child,#main>div>h4:first-child,#main>div>p:first-child{margin-top:0px;padding-top:0px;} ul,ol{padding:0 0 0 0;margin:0 0 0 0;} .row,[class*="span"]{margin-left:0px;} -.navbar .nav{float:none;}.navbar .nav>li>a{border-left:none;border-right:none;} +.navbar .nav{float:none;}.navbar .nav>li.dropdown.open>.dropdown-toggle{background-color:inherit;text-decoration:underline;} +.navbar .nav>li>a{border-left:none;border-right:none;} .navbar .nav>li>a:hover,.navbar .nav>li>a:focus{background-color:inherit;border-left:none;border-right:none;} .navbar .nav>li:nth-child(even){border-left:1px solid #c0c0c0;border-right:1px solid #c0c0c0;} .navbar .nav>.navbar .nav>li:last-child{border-right:none;} .navbar .nav .active a,.navbar .nav .active a:hover,.navbar .nav .active a:focus{background-color:white;box-shadow:none;} -.navbar .navbar-inner{background-image:url('../img/chalkboard_bg.jpg');-webkit-box-shadow:inset 0px -10px 20px 0px rgba(0, 0, 0, 0.25);-moz-box-shadow:inset 0px -10px 20px 0px rgba(0, 0, 0, 0.25);box-shadow:inset 0px -10px 20px 0px rgba(0, 0, 0, 0.25);padding-top:1em;} +.navbar .navbar-inner{background-image:url('../img/chalkboard_bg.jpg');-webkit-box-shadow:inset 0px -10px 20px 0px rgba(0, 0, 0, 0.25);-moz-box-shadow:inset 0px -10px 20px 0px rgba(0, 0, 0, 0.25);box-shadow:inset 0px -10px 20px 0px rgba(0, 0, 0, 0.25);padding-top:25px;} .navbar .nav-wrap{width:100%;padding:0 0 0 0;margin:0 0 0 0;width:940px;margin-bottom:-24px;-webkit-box-shadow:0 0 8px 3px rgba(0, 0, 0, 0.25);-moz-box-shadow:0 0 8px 3px rgba(0, 0, 0, 0.25);box-shadow:0 0 8px 3px rgba(0, 0, 0, 0.25);background:white;text-align:center;}.navbar .nav-wrap ul{display:inline-block;margin:0 0 0 0;}.navbar .nav-wrap ul>li{float:none;display:inline-block;}.navbar .nav-wrap ul>li>a{text-shadow:none;color:#333333;text-transform:uppercase;} .navbar .nav-wrap ul>li>a:focus,.navbar .nav-wrap ul>li>a:hover{color:#c0c0c0;} .navbar .nav-wrap ul>li:last-child{border-right:none;} @@ -20,16 +22,16 @@ ul,ol{padding:0 0 0 0;margin:0 0 0 0;} .footer a.logo.mac{background-image:url('../img/mac.png');} .footer a.logo.moz{background-image:url('../img/moz.png');} .footer a:hover{color:#c0c0c0;text-decoration:none;} -.footer .lower{border-top:1px solid white;padding-top:20px;}.footer .lower p{background-image:url('../img/csol_logo_sm.png');padding-left:209px;background-repeat:no-repeat;background-position:top left;width:400px;min-height:131px;margin-bottom:20px;} -.footer .lower li{border-left:none;border-right:none;} -.footer .upper{padding:1em 0;} +.footer .upper{padding:50px 0 0 0;}.footer .upper p{background-image:url('../img/csol_logo_sm.png');padding-left:209px;background-repeat:no-repeat;background-position:top left;width:400px;min-height:131px;margin-bottom:20px;} +.footer .upper li{border-left:none;border-right:none;} +.footer .lower{border-top:1px solid white;padding:25px 0 0 0;} .footer ul li{display:inline-block;}.footer ul li>a,.footer ul li>span{margin:10px;} .footer ul li:first-child a{margin-left:0px;} .footer ul li.nth-child(even){border-left:1px solid #fff;border-right:1px solid #fff;} .footer ul li.last-child{border-right:none;} .wrapper{width:100%;}.wrapper .inner-wrapper{width:100%;} .wrapper.primary{min-height:100%;height:auto !important;height:100%;margin:0px 0px -250px 0px;}.wrapper.primary .inner-wrapper{padding:0px 0px 250px 0px;} -.wrapper.secondary{color:#fff;height:250px;background-image:url('../img/chalkboard_bg.jpg');} +.wrapper.secondary{position:relative;color:#fff;height:250px;background-image:url('../img/background-chalkboard-green.jpg');background-repeat:no-repeat;-webkit-background-size:cover;-moz-background-size:cover;-o-background-size:cover;background-size:cover;filter:progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/background-chalkboard-green.jpg', sizingMethod='scale');-ms-filter:"progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/background-chalkboard-green.jpg', sizingMethod='scale')";overflow:visible;} .poster a,.poster img{display:block;margin-left:auto;margin-right:auto;} #menu-login-form{padding:10px;text-align:left;} input[type="password"].metered{padding-bottom:9px;}input[type="password"].metered+.password-meter{border:solid 1px #CCC;border-top:none;height:6px;margin:-6px 0 10px;-webkit-border-radius:0 0 4px 4px;-moz-border-radius:0 0 4px 4px;border-radius:0 0 4px 4px;-moz-box-sizing:border-box;box-sizing:border-box;position:relative;overflow:hidden;-webkit-transition:border linear .2s;-moz-transition:border linear .2s;-o-transition:border linear .2s;transition:border linear .2s;} @@ -37,3 +39,23 @@ input[type="password"].metered+.password-meter .bar{border-radius:0 0 0 3px;over input[type="password"].metered:focus+.password-meter{border-color:rgba(82, 168, 236, 0.8);} input[type="password"].metered:focus:invalid+.password-meter{border-color:#ee5f5b;} input[type="password"].metered:focus:invalid:focus+.password-meter{border-color:#e9322d;} +body.home{background-image:url('../img/chalkboard_bg.jpg');}body.home .container{width:960px;} +body.home .navbar .navbar-inner{background-image:url('../img/spacedimg.jpg');background-repeat:no-repeat;background-position:top center;-webkit-background-size:cover;-moz-background-size:cover;-o-background-size:cover;background-size:cover;padding-top:0px;filter:progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/spacedimg.png', sizingMethod='scale');-ms-filter:"progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/spacedimg.png', sizingMethod='scale')";overflow:visible;max-height:515px;}body.home .navbar .navbar-inner>.container{background-image:url('../img/home_drawing.png');background-position:top center;background-repeat:no-repeat;} +body.home .navbar .brand{background-image:url('../img/csol_logo_sm.png');position:absolute;left:50%;margin-left:-102px;z-index:2;top:50px;width:189px;height:131px;padding:0 0 0 0;} +body.home .navbar .nav-wrap{width:inherit;margin-bottom:0px;height:600px;width:310px;margin:0 auto;background:none;background-image:url('../img/banner300.png');background-position:top center;background-repeat:no-repeat;position:relative;z-index:1;box-shadow:none;}body.home .navbar .nav-wrap ul{padding-top:200px;left:-6px;}body.home .navbar .nav-wrap ul>li{text-align:left;display:block;line-height:9px;}body.home .navbar .nav-wrap ul>li>a{text-transform:inherit;font-size:22px;color:#f4fc00;text-transform:lowercase;} +body.home .navbar .nav-wrap ul>li.learn>a:first-letter{text-transform:capitalize;} +body.home .navbar .nav-wrap ul>li.log-in>a{font-size:18px;color:#fff;text-transform:lowercase;text-align:center;line-height:24px;} +body.home .navbar .nav-wrap ul>li.video{margin-top:35px;}body.home .navbar .nav-wrap ul>li.video a{color:#fff;border-radius:7px;margin:auto;display:block;width:140px;text-align:center;line-height:20px;background:#e82202;background:-moz-linear-gradient(top, #e82202 0%, #e5381d 44%, #e56854 100%);background:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #e82202), color-stop(44%, #e5381d), color-stop(100%, #e56854));background:-webkit-linear-gradient(top, #e82202 0%, #e5381d 44%, #e56854 100%);background:-o-linear-gradient(top, #e82202 0%, #e5381d 44%, #e56854 100%);background:-ms-linear-gradient(top, #e82202 0%, #e5381d 44%, #e56854 100%);background:linear-gradient(to bottom, #e82202 0%, #e5381d 44%, #e56854 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#e82202', endColorstr='#e56854', GradientType=0);} +body.home .navbar .nav-wrap ul>li span{color:#fff;} +body.home .navbar .nav-wrap ul>li:nth-child(even){border-left:none;border-right:none;} +body.home #main{padding:0 0 0 0;display:none;} +body.home .navbar-static-top .container{width:inherit;} +body.home .secondary .upper{padding-top:15px;position:relative;}body.home .secondary .upper>p.pull-left{display:none;} +body.home .secondary .upper>.pull-right{float:left;text-align:center;width:340px;padding-top:55px;} +body.home .secondary .lower{border-top:none;}body.home .secondary .lower .pull-right li:first-child{display:none;} +body.home .secondary .lower .pull-right li:nth-child(3){border-left:1px solid #fff;border-right:1px solid #fff;} +body.home .secondary .lower .pull-left li:first-child{border-right:1px solid #fff;} +body.home .footer{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;} +body.home #rahm{position:relative;width:210px;padding-left:100px;padding-top:15px;background-image:url('../img/rahm.png');background-repeat:no-repeat;background-position:center left;float:left;-webkit-hyphens:none;-moz-hyphens:none;hyphens:none;} +body.home #bubbles{float:left;background-image:url('../img/bubbles.png');width:310px;height:140px;background-repeat:no-repeat;background-position:18px 0px;margin-top:15px;}body.home #bubbles span{color:#000;display:block;width:100px;}body.home #bubbles span.lt{float:left;margin-left:34px;margin-top:12px;}body.home #bubbles span.lt a{color:#3B5998;} +body.home #bubbles span.rt{margin-top:52px;margin-right:7px;float:right;font-size:24px;line-height:20px;} diff --git a/static/media/img/background-chalkboard-green.jpg b/static/media/img/background-chalkboard-green.jpg new file mode 100644 index 0000000..cfcf104 Binary files /dev/null and b/static/media/img/background-chalkboard-green.jpg differ diff --git a/static/media/img/banner275.png b/static/media/img/banner275.png new file mode 100644 index 0000000..0d533e6 Binary files /dev/null and b/static/media/img/banner275.png differ diff --git a/static/media/img/banner300.png b/static/media/img/banner300.png new file mode 100644 index 0000000..6ee0aeb Binary files /dev/null and b/static/media/img/banner300.png differ diff --git a/static/media/img/bubbles.png b/static/media/img/bubbles.png new file mode 100644 index 0000000..3316ac4 Binary files /dev/null and b/static/media/img/bubbles.png differ diff --git a/static/media/img/home_drawing.png b/static/media/img/home_drawing.png new file mode 100644 index 0000000..40a5393 Binary files /dev/null and b/static/media/img/home_drawing.png differ diff --git a/static/media/img/rahm.png b/static/media/img/rahm.png new file mode 100644 index 0000000..c70281a Binary files /dev/null and b/static/media/img/rahm.png differ diff --git a/static/media/img/spacedimg.jpg b/static/media/img/spacedimg.jpg new file mode 100644 index 0000000..b2e92f5 Binary files /dev/null and b/static/media/img/spacedimg.jpg differ diff --git a/static/media/js/custom_logic.js b/static/media/js/custom_logic.js index a3ef9e9..54ae245 100644 --- a/static/media/js/custom_logic.js +++ b/static/media/js/custom_logic.js @@ -1,3 +1,12 @@ $(document).ready(function(){ $('.show-tooltip').tooltip(); + if($('body.home').length != 0) { + $('

This summer Mayor Rahm Emanuel is challenging all Chicago youth to participate in the Summer of Learning. School stops for the summer, but learning never should.

').prependTo('.footer .upper'); + $('
join the conversation on Facebook.share stories
').appendTo('.footer .upper'); + $('li.challenges').after('
  • watch video
  • '); + $('li.learn a').append(' your city'); + $('li.badges a').append(' Badges &'); + $('li.challenges a').append(' your future.'); + + } }); \ No newline at end of file diff --git a/static/media/less/core.less b/static/media/less/core.less index 7490f9b..f688544 100644 --- a/static/media/less/core.less +++ b/static/media/less/core.less @@ -1,14 +1,18 @@ // Target: ../css/core.css @import "mixins.less"; +@import url(http://fonts.googleapis.com/css?family=Open+Sans:400,300); /*general*/ html, body { - height: 100%; + height:100%; + font-family:'Open Sans', sans-serif; + font-weight:300; + line-height:18px; } body { - margin: 0px; - padding: 0px; + margin:0px; + padding:0px; .container { & > br { display:none; @@ -28,8 +32,8 @@ body { } } ul, ol { - padding: 0 0 0 0; - margin: 0 0 0 0; + padding:0 0 0 0; + margin:0 0 0 0; } .row, [class*="span"] { @@ -41,6 +45,10 @@ ul, ol { .nav { float:none; & > li { + &.dropdown.open > .dropdown-toggle { + background-color:inherit; + text-decoration:underline; + } & > a { border-left:none; border-right:none; @@ -67,10 +75,10 @@ ul, ol { } .navbar-inner { background-image:url('../img/chalkboard_bg.jpg'); - -webkit-box-shadow: inset 0px -10px 20px 0px rgba(0, 0, 0, 0.25); - -moz-box-shadow: inset 0px -10px 20px 0px rgba(0, 0, 0, 0.25); - box-shadow: inset 0px -10px 20px 0px rgba(0, 0, 0, 0.25); - padding-top:1em; + -webkit-box-shadow:inset 0px -10px 20px 0px rgba(0, 0, 0, 0.25); + -moz-box-shadow:inset 0px -10px 20px 0px rgba(0, 0, 0, 0.25); + box-shadow:inset 0px -10px 20px 0px rgba(0, 0, 0, 0.25); + padding-top:25px; } .nav-wrap { width:100%; @@ -78,17 +86,17 @@ ul, ol { margin:0 0 0 0; width:940px; margin-bottom:-24px; - -webkit-box-shadow: 0 0 8px 3px rgba(0, 0, 0, 0.25); - -moz-box-shadow: 0 0 8px 3px rgba(0, 0, 0, 0.25); - box-shadow: 0 0 8px 3px rgba(0, 0, 0, 0.25); - background: white; - text-align: center; + -webkit-box-shadow:0 0 8px 3px rgba(0, 0, 0, 0.25); + -moz-box-shadow:0 0 8px 3px rgba(0, 0, 0, 0.25); + box-shadow:0 0 8px 3px rgba(0, 0, 0, 0.25); + background:white; + text-align:center; ul { display:inline-block; margin:0 0 0 0; & > li { float:none; - display: inline-block; + display:inline-block; & > a { text-shadow:none; color:#333333; @@ -104,32 +112,32 @@ ul, ol { } } .brand { - display: block; + display:block; width:671px; height:220px; background-image:url('../img/csol_logo.png'); - background-repeat: no-repeat; + background-repeat:no-repeat; margin:0 auto; float:none; - text-indent: -9000px; + text-indent:-9000px; } } /*footer*/ .footer { - -webkit-box-shadow: inset 0px 10px 20px 0px rgba(0, 0, 0, 0.25); - -moz-box-shadow: inset 0px 10px 20px 0px rgba(0, 0, 0, 0.25); - box-shadow: inset 0px 10px 20px 0px rgba(0, 0, 0, 0.25); + -webkit-box-shadow:inset 0px 10px 20px 0px rgba(0, 0, 0, 0.25); + -moz-box-shadow:inset 0px 10px 20px 0px rgba(0, 0, 0, 0.25); + box-shadow:inset 0px 10px 20px 0px rgba(0, 0, 0, 0.25); a { color:#fff; } a.logo { - text-indent: -9000px; + text-indent:-9000px; width:82px; height:82px; display:block; - background-position: center center; - background-repeat: no-repeat; + background-position:center center; + background-repeat:no-repeat; } a.logo.chi { background-image:url('../img/chi.png'); @@ -142,16 +150,15 @@ ul, ol { } a:hover { color:#c0c0c0; - text-decoration: none; + text-decoration:none; } - .lower { - border-top:1px solid white; - padding-top:20px; + .upper { + padding:50px 0 0 0; p { - background-image: url('../img/csol_logo_sm.png'); + background-image:url('../img/csol_logo_sm.png'); padding-left:209px; - background-repeat: no-repeat; - background-position: top left; + background-repeat:no-repeat; + background-position:top left; width:400px; min-height:131px; margin-bottom:20px; @@ -161,12 +168,13 @@ ul, ol { border-right:none; } } - .upper { - padding:1em 0; + .lower { + border-top:1px solid white; + padding:25px 0 0 0; } ul { li { - display: inline-block; + display:inline-block; & > a, & > span { margin:10px; } @@ -193,18 +201,27 @@ ul, ol { width:100% } &.primary { - min-height: 100%; - height: auto !important; - height: 100%; - margin: 0px 0px -250px 0px; + min-height:100%; + height:auto !important; + height:100%; + margin:0px 0px -250px 0px; .inner-wrapper { - padding: 0px 0px 250px 0px; + padding:0px 0px 250px 0px; } } &.secondary { + position:relative; color:#fff; - height: 250px; - background-image:url('../img/chalkboard_bg.jpg'); + height:250px; + background-image:url('../img/background-chalkboard-green.jpg'); + background-repeat:no-repeat; + -webkit-background-size:cover; + -moz-background-size:cover; + -o-background-size:cover; + background-size:cover; + filter:progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/background-chalkboard-green.jpg', sizingMethod='scale'); + -ms-filter:"progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/background-chalkboard-green.jpg', sizingMethod='scale')"; + overflow:visible; } } @@ -258,4 +275,200 @@ input[type="password"].metered { &:focus:invalid:focus + .password-meter { border-color: #e9322d; } -} \ No newline at end of file +} + +/*landing page specific*/ +body.home { + background-image:url('../img/chalkboard_bg.jpg'); + .container { + width:960px; + } + .navbar { + .navbar-inner { + background-image:url('../img/spacedimg.jpg'); + background-repeat:no-repeat; + background-position:top center; + -webkit-background-size:cover; + -moz-background-size:cover; + -o-background-size:cover; + background-size:cover; + padding-top:0px; + filter:progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/spacedimg.png', sizingMethod='scale'); + -ms-filter:"progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/spacedimg.png', sizingMethod='scale')"; + overflow:visible; + max-height:515px; + + & > .container { + background-image:url('../img/home_drawing.png'); + background-position:top center; + background-repeat:no-repeat; + } + } + .brand { + background-image:url('../img/csol_logo_sm.png'); + position:absolute; + left:50%; + margin-left:-102px; + z-index:2; + top:50px; + width:189px; + height:131px; + padding:0 0 0 0; + } + .nav-wrap { + width:inherit; + margin-bottom:0px; + height:600px; + width:310px; + margin:0 auto; + background:none; + background-image:url('../img/banner300.png'); + background-position:top center; + background-repeat:no-repeat; + position:relative; + z-index:1; + box-shadow:none; + ul { + padding-top:200px; + left:-6px; + & > li { + text-align:left; + display:block; + line-height:9px; + & > a { + text-transform:inherit; + font-size:22px; + color:#f4fc00; + text-transform:lowercase; + } + &.learn > a:first-letter { + text-transform:capitalize; + } + &.log-in > a { + font-size:18px; + color:#fff; + text-transform:lowercase; + text-align:center; + line-height:24px; + } + &.video { + margin-top:35px; + a { + color:#fff; + border-radius:7px; + margin:auto; + display:block; + width:140px; + text-align:center; + line-height:20px; + background:#e82202; + background:-moz-linear-gradient(top, #e82202 0%, #e5381d 44%, #e56854 100%); + background:-webkit-gradient(linear, left top, left bottom, color-stop(0%,#e82202), color-stop(44%,#e5381d), color-stop(100%,#e56854)); + background:-webkit-linear-gradient(top, #e82202 0%,#e5381d 44%,#e56854 100%); + background:-o-linear-gradient(top, #e82202 0%,#e5381d 44%,#e56854 100%); + background:-ms-linear-gradient(top, #e82202 0%,#e5381d 44%,#e56854 100%); + background:linear-gradient(to bottom, #e82202 0%,#e5381d 44%,#e56854 100%); + filter:progid:DXImageTransform.Microsoft.gradient( startColorstr='#e82202', endColorstr='#e56854',GradientType=0 ); + } + } + span { + color:#fff; + } + } + & > li:nth-child(even) { + border-left:none; + border-right:none; + } + } + } + } + #main { + padding:0 0 0 0; + display:none; + + } + .navbar-static-top { + .container { + width:inherit; + } + } + .secondary { + .upper { + padding-top:15px; + position:relative; + > p.pull-left { + display:none; + } + > .pull-right { + float:left; + text-align:center; + width:340px; + padding-top:55px; + } + } + .lower { + border-top:none; + .pull-right { + li:first-child { + display:none; + } + li:nth-child(3) { + border-left:1px solid #fff; + border-right:1px solid #fff; + } + } + .pull-left { + li:first-child { + border-right:1px solid #fff; + } + } + } + } + .footer { + -webkit-box-shadow:none; + -moz-box-shadow:none; + box-shadow:none; + } + #rahm { + position:relative; + width:210px; + padding-left:100px; + padding-top:15px; + background-image:url('../img/rahm.png'); + background-repeat:no-repeat; + background-position:center left; + float:left; + -webkit-hyphens:none; + -moz-hyphens:none; + hyphens:none; + } + #bubbles { + float:left; + background-image:url('../img/bubbles.png'); + width:310px; + height:140px; + background-repeat:no-repeat; + background-position:18px 0px; + margin-top:15px; + span { + color:#000; + display:block; + width:100px; + &.lt { + float:left; + margin-left:34px; + margin-top:12px; + a { + color:#3B5998; + } + } + &.rt { + margin-top:52px; + margin-right:7px; + float:right; + font-size:24px; + line-height:20px; + } + } + } +} diff --git a/test/api-middleware.test.js b/test/api-middleware.test.js index 4e241d6..ba4615b 100644 --- a/test/api-middleware.test.js +++ b/test/api-middleware.test.js @@ -319,6 +319,9 @@ test('api.middleware(method)', function(t) { }); +/* Implicitly testing the wrapped request methods just by + testing get, which is a bit lame but quicker. */ +/* TODO: also test the underlying api.remote()? */ test('api.get', function(t) { var api = new Api(ORIGIN); @@ -333,6 +336,21 @@ test('api.get', function(t) { t.end(); }); + t.test('passes optional params through', function(t) { + var requestMock = sinon.mock(request); + var get = requestMock.expects('get'); + + api.get('/foo', { some: 'params' }, function(){}); + t.ok(get.calledOnce, 'called'); + console.log(get.args); + t.ok(get.calledWith( + sinon.match(ORIGIN + '/foo'), + sinon.match({ some: 'params' }) + ), 'with params too'); + requestMock.restore(); + t.end(); + }); + t.test('leading slashes don\'t indicate absolute path', function(t) { const WITH_PATH = 'http://example.org/base/'; var api = new Api(WITH_PATH); @@ -363,7 +381,7 @@ test('api.get', function(t) { t.test('calls callback with 500 if request.get errors', function(t) { var requestMock = sinon.mock(request); - var get = requestMock.expects('get').callsArgWith(1, 'Error'); + var get = requestMock.expects('get').callsArgWith(2, 'Error'); api.get('/foo', function(err, data){ t.similar(err, { code: 500, name: 'Internal', message: 'Error' }, 'error'); @@ -375,7 +393,7 @@ test('api.get', function(t) { t.test('calls callback with 500 if request.get response is not 200', function(t) { var requestMock = sinon.mock(request); - var get = requestMock.expects('get').callsArgWith(1, null, { statusCode: 404 }); + var get = requestMock.expects('get').callsArgWith(2, null, { statusCode: 404 }); api.get('/foo', function(err, data){ t.similar(err, { code: 404, name: 'NotFound' }, 'error'); @@ -388,7 +406,7 @@ test('api.get', function(t) { t.test('calls callback with 500 if request.get response is not json', function(t) { var requestMock = sinon.mock(request); var get = requestMock.expects('get') - .callsArgWith(1, null, { statusCode: 200 }, "NOPE!"); + .callsArgWith(2, null, { statusCode: 200 }, "NOPE!"); api.get('/foo', function(err, data){ t.similar(err, { code: 500, name: 'Internal', message: 'Unexpected token N' }, 'error'); @@ -406,7 +424,7 @@ test('api.get', function(t) { var requestMock = sinon.mock(request); var get = requestMock.expects('get') - .callsArgWith(1, null, { statusCode: 200 }, JSON.stringify(response)); + .callsArgWith(2, null, { statusCode: 200 }, JSON.stringify(response)); api.get('/foo', function(err, data){ t.similar(err, { code: 500, name: 'Internal', message: 'It broke.' }, 'error'); @@ -416,7 +434,7 @@ test('api.get', function(t) { }); }); - t.test('successful call passes data through', function(t) { + t.test('stringified JSON data gets parsed', function(t) { var response = { status: 'ok', data: 'Stuff.' @@ -424,7 +442,26 @@ test('api.get', function(t) { var requestMock = sinon.mock(request); var get = requestMock.expects('get') - .callsArgWith(1, null, { statusCode: 200 }, JSON.stringify(response)); + .callsArgWith(2, null, { statusCode: 200 }, JSON.stringify(response)); + + api.get('/foo', function(err, data){ + t.notOk(err, 'no error'); + t.same(data, { status: 'ok', data: 'Stuff.' }, 'data'); + requestMock.restore(); + t.end(); + }); + }); + + /* request parses for you if you post with { json: ... } */ + t.test('pre-parsed data is passed through', function(t) { + var response = { + status: 'ok', + data: 'Stuff.' + }; + + var requestMock = sinon.mock(request); + var get = requestMock.expects('get') + .callsArgWith(2, null, { statusCode: 200 }, response); api.get('/foo', function(err, data){ t.notOk(err, 'no error'); diff --git a/test/openbadger.test.js b/test/openbadger.test.js index 7464958..d1bbedd 100644 --- a/test/openbadger.test.js +++ b/test/openbadger.test.js @@ -77,6 +77,10 @@ const DATA = { url: "http://issuer-b.org" } } + }, + 'claim': { + status: 'ok', + url: 'http://some-url.org/assertion' } }; @@ -249,3 +253,23 @@ test('getIssuers', function(t) { }); }); + +test('claim', function(t) { + + t.test('with data', function(t) { + var postStub = mock.expects('post'); + postStub.callsArgWith(2, null, DATA['claim']); + openbadger.claim({ + code: 'CLAIMCODE', + email: 'EMAIL' + }, function(err, data) { + t.notOk(err, 'no error'); + var opts = postStub.args[0][1]; + t.ok(opts.json, 'post with json data'); + t.ok(opts.json.auth, 'contains auth'); + t.similar(opts.json, { email: 'EMAIL', code: 'CLAIMCODE' }, 'params'); + t.end(); + }); + }); + +}); diff --git a/test/organization-model.test.js b/test/organization-model.test.js deleted file mode 100644 index f87528f..0000000 --- a/test/organization-model.test.js +++ /dev/null @@ -1,27 +0,0 @@ -const $ = require('./'); -const test = require('tap').test; -const Organization = require('../models/organization'); - -$.prepare([ - { model: 'Organization', - name: 'wbez', - values: { - id: 10, - name: 'WBEZ 91.5', - description: 'Chicago Public Radio', - url: 'http://www.wbez.org/', - imageUrl: 'https://twimg0-a.akamaihd.net/profile_images/858641792/WBEZ915_LOGO.jpg', - address: '848 East Grand Ave, Navy Pier, Chicago, Illinois 60611', - phone: '312.948.4600', - email: 'admin@wbez.org' - } - } -], function (fixtures) { - test('Finding an organization', function (t) { - const expect = fixtures['wbez']; - Organization.find(expect.id).success(function (instance) { - t.same(instance.rawAttributes, expect.rawAttributes); - t.end(); - }); - }); -}); diff --git a/views/badges/claim.html b/views/badges/claim.html index b9fa40d..6269b3a 100644 --- a/views/badges/claim.html +++ b/views/badges/claim.html @@ -3,27 +3,28 @@ {% set user = {} %} {% block content %} -
    -
    - -
    -
    -

    Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

    -
    -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    +
    +
    + +
    +
    +

    To claim the badge you've earned, enter the "claim code" written on the paper badge you received from your teacher, counselor or mentor.

    +

    Then hit the "Claim This Badge" button on your screen. Once you do this, the badge will be added to your dashboard and you can throw the piece of paper away (better yet, recycle it!).

    +
    +
    +
    + +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    {% endblock %} diff --git a/views/badges/single.html b/views/badges/single.html index 9518a83..8b53887 100644 --- a/views/badges/single.html +++ b/views/badges/single.html @@ -2,104 +2,177 @@ {% set pageTitle = badge.name %} {% block content %} -
    -
    - -
    -
    -

    {{ badge.description }}

    +
    +
    + +
    +
    +

    What is this badge about?

    +

    {{ badge.description }}

    -

    - Claim this badge - Apply - Add to your favorites -

    -
    -
    +

    How can you earn it?

    -

    Similar or Related Badges

    -

    If you're interested in this badge, you might be interested in these too. These are badges on similar STEAM topics or ones that can take you to the next level.

    - +

    + Claim this badge + Apply + +

    +
    +
    +

    Similar or Related Badges

    +

    If you're interested in this badge, you might be interested in these too. These are badges on similar STEAM topics or ones that can take you to the next level.

    + {% endblock %} {% block modal %} + + + + {% endblock %} \ No newline at end of file diff --git a/views/layout.html b/views/layout.html index 32426db..3d9b937 100644 --- a/views/layout.html +++ b/views/layout.html @@ -21,7 +21,7 @@