diff --git a/app/assets/images/pages/play/modal/subscribe-heroes.png b/app/assets/images/pages/play/modal/subscribe-heroes.png new file mode 100644 index 000000000..bd55cf02b Binary files /dev/null and b/app/assets/images/pages/play/modal/subscribe-heroes.png differ diff --git a/app/locale/en.coffee b/app/locale/en.coffee index dabab010d..4d81e5004 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -381,6 +381,15 @@ recovered: "Previous gems purchase recovered. Please refresh the page." subscribe: + comparison_blurb: "Sharpen your skills with a CodeCombat subscription!" + feature1: "60+ basic levels across 4 worlds" + feature2: "7 powerful new heroes with unique skills!" + feature3: "30+ bonus levels" + feature4: "3500 bonus gems every month!" + feature5: " Video tutorials" + feature6: "Premium email support" + free: "Free" + month: "month" subscribe_title: "Subscribe" unsubscribe: "Unsubscribe" confirm_unsubscribe: "Confirm Unsubscribe" @@ -394,12 +403,20 @@ heroes: "More powerful heroes!" gems: "3500 bonus gems every month!" items: "Over 250 bonus items!" + parent_button: "Ask your parent" + parent_email_description: "We'll email them so they can buy you a CodeCombat subscription." + parent_email_input_invalid: "Email address invalid." + parent_email_input_label: "Parent email address" + parent_email_input_placeholder: "Enter parent email" + parent_email_send: "Send Email" + parent_email_sent: "Email sent!" + parent_email_title: "What's your parent's email?" parents: "For Parents" parents_title: "Your child will learn to code." parents_blurb1: "With CodeCombat, your child learns by writing real code. They start by learning simple commands, and progress to more advanced topics." parents_blurb2: "For $9.99 USD/mo, they get new challenges every week and personal email support from professional programmers." parents_blurb3: "No Risk: 100% money back guarantee, easy 1-click unsubscribe." - subscribe_button: "Subscribe Now" + subscribe_button: "Subscribe" stripe_description: "Monthly Subscription" subscription_required_to_play: "You'll need a subscription to play this level." unlock_help_videos: "Subscribe to unlock all video tutorials." diff --git a/app/schemas/models/user.coffee b/app/schemas/models/user.coffee index 1d6396ac0..a499decdd 100644 --- a/app/schemas/models/user.coffee +++ b/app/schemas/models/user.coffee @@ -85,6 +85,12 @@ _.extend UserSchema.properties, recruitNotes: {$ref: '#/definitions/emailSubscription'} employerNotes: {$ref: '#/definitions/emailSubscription'} + oneTimes: c.array {title: 'One-time emails'}, + c.object {title: 'One-time email', required: ['type', 'targetEmail']}, + type: c.shortString() # E.g 'subscribe modal parent' + targetEmail: c.shortString() + sent: c.date() # Set when sent + # server controlled permissions: c.array {}, c.shortString() dateCreated: c.date({title: 'Date Joined'}) @@ -272,7 +278,7 @@ _.extend UserSchema.properties, purchased: c.RewardSchema 'purchased with gems or money' spent: {type: 'number'} stripeCustomerID: { type: 'string' } # TODO: Migrate away from this property - + stripe: c.object {}, { customerID: { type: 'string' } planID: { enum: ['basic'] } diff --git a/app/styles/modal/subscribe-modal.sass b/app/styles/modal/subscribe-modal.sass index 96a89f967..8a56a0895 100644 --- a/app/styles/modal/subscribe-modal.sass +++ b/app/styles/modal/subscribe-modal.sass @@ -51,36 +51,11 @@ &:hover color: yellow - - //- Selling points - - #selling-points - position: absolute - left: 65px - top: 335px - width: 650px - font-weight: bold - line-height: 18px - color: black - font-family: $headings-font-family - font-size: 18px - - .point - width: 150px - overflow: none - float: left - text-align: center - margin-right: 10px - - #parents-info - position: absolute - right: 7px - top: 56px - text-decoration: underline - cursor: pointer + //- Popovers .popover z-index: 1050 + min-width: 400px h3 background: transparent @@ -88,13 +63,67 @@ font-size: 30px color: black + //- Sales image + + .subscribe-image + position: absolute + top: 114px + right: 65px + + //- Feature comparison table + + .comparison-blurb + position: absolute + left: 10% + top: 132px + width: 450px + background: rgba(0, 0, 0, 0.0) + font-weight: normal + line-height: 18px + color: black + font-family: $headings-font-family + font-size: 18px + + .comparison-table + position: absolute + left: 10% + top: 160px + width: 450px + background: rgba(0, 0, 0, 0.0) + thead + tr + th + font-size: 24px + font-variant: small-caps + font-family: "Open Sans Condensed", "Helvetica Neue", Helvetica, Arial, sans-serif + font-weight: 700 + line-height: 1.1 + color: #317EAC + tbody + font-size: 14px + .center-ok + text-align: center + + //- Parent info popover link + + #parents-info + position: absolute + left: 38px + top: 389px + text-decoration: underline + cursor: pointer + font-weight: bold + line-height: 18px + color: black + font-family: $headings-font-family + font-size: 18px //- Purchase button .purchase-button position: absolute - left: 73px - width: 600px + right: 24px + width: 400px height: 70px top: 430px font-size: 32px @@ -116,6 +145,28 @@ padding: 2px 0 0 2px color: white + //- Parent button + //- TODO: Add hover and active effects + + .parent-button + position: absolute + left: 24px + width: 250px + height: 70px + top: 430px + font-size: 28px + line-height: 38px + border-style: solid + border-image: url(/images/common/button-background-warning-disabled.png) 14 20 20 20 fill round + border-width: 14px 20px 20px 20px + color: darken(white, 5%) + + #email-parent-form + .email_invalid + color: red + display: none + #email-parent-complete + display: none //- Errors diff --git a/app/templates/core/subscribe-modal.jade b/app/templates/core/subscribe-modal.jade index ecf13ba2d..3071b2c5e 100644 --- a/app/templates/core/subscribe-modal.jade +++ b/app/templates/core/subscribe-modal.jade @@ -7,27 +7,67 @@ #retrying-alert.alert.alert-danger(data-i18n="buy_gems.retrying") else - img(src="/images/pages/play/modal/subscribe-background.png")#subscribe-background + img#subscribe-background(src="/images/pages/play/modal/subscribe-background-blank.png") + img.subscribe-image(src="/images/pages/play/modal/subscribe-heroes.png") h1(data-i18n="subscribe.subscribe_title") Subscribe div#close-modal span.glyphicon.glyphicon-remove - #selling-points - #point-levels.point - .blurb(data-i18n="subscribe.levels") - #point-heroes.point - .blurb(data-i18n="subscribe.heroes") - #point-gems.point - .blurb(data-i18n="subscribe.gems") - #point-items.point - .blurb(data-i18n="subscribe.items") - - #parents-info(data-i18n="subscribe.parents") + div.comparison-blurb(data-i18n="subscribe.comparison_blurb") + table.table.table-condensed.table-bordered.comparison-table + thead + tr + th + th(data-i18n="subscribe.free") + th + //- TODO: find a better way to localize '$9.99/month' + span $#{price}/ + span(data-i18n="subscribe.month") + tbody + tr + td.feature-description + span(data-i18n="subscribe.feature1") + td.center-ok + span.glyphicon.glyphicon-ok + td.center-ok + span.glyphicon.glyphicon-ok + tr + td.feature-description + span(data-i18n="[html]subscribe.feature2") + td + td.center-ok + span.glyphicon.glyphicon-ok + tr + td.feature-description + span(data-i18n="subscribe.feature3") + td + td.center-ok + span.glyphicon.glyphicon-ok + tr + td.feature-description + span(data-i18n="[html]subscribe.feature4") + td + td.center-ok + span.glyphicon.glyphicon-ok + tr + td.feature-description + span(data-i18n="subscribe.feature5") + td + td.center-ok + span.glyphicon.glyphicon-ok + tr + td.feature-description + span(data-i18n="subscribe.feature6") + td + td.center-ok + span.glyphicon.glyphicon-ok + #parents-info(data-i18n="subscribe.parents") button.btn.btn-lg.btn-illustrated.purchase-button(data-i18n="subscribe.subscribe_button") - + button.btn.btn-lg.btn-illustrated.parent-button(data-i18n="subscribe.parent_button") + if state === 'declined' #declined-alert.alert.alert-danger.alert-dismissible span(data-i18n="buy_gems.declined") diff --git a/app/views/core/SubscribeModal.coffee b/app/views/core/SubscribeModal.coffee index 01dfe153c..bf729b381 100644 --- a/app/views/core/SubscribeModal.coffee +++ b/app/views/core/SubscribeModal.coffee @@ -17,8 +17,9 @@ module.exports = class SubscribeModal extends ModalView 'stripe:received-token': 'onStripeReceivedToken' events: - 'click .purchase-button': 'onClickPurchaseButton' 'click #close-modal': 'hide' + 'click #parent-send': 'onClickParentSendButton' + 'click .purchase-button': 'onClickPurchaseButton' constructor: (options) -> super(options) @@ -34,6 +35,40 @@ module.exports = class SubscribeModal extends ModalView afterRender: -> super() + @setupParentButtonPopover() + @setupParentInfoPopover() + + setupParentButtonPopover: -> + popoverTitle = $.i18n.t 'subscribe.parent_email_title' + popoverTitle += '' + popoverContent = "
#{$.i18n.t('subscribe.parent_email_description')}
" + popoverContent += "" + popoverContent += "#{$.i18n.t('subscribe.parent_email_sent')}
" + popoverContent += " " + popoverContent += "" + $.i18n.t('subscribe.parents_blurb1') + "
" popoverContent += "" + $.i18n.t('subscribe.parents_blurb2') + "
" @@ -50,6 +85,26 @@ module.exports = class SubscribeModal extends ModalView ).on 'shown.bs.popover', => application.tracker?.trackEvent 'Subscription parent hover', {} + onClickParentSendButton: (e) -> + # TODO: Popover sometimes dismisses immediately after send + + email = $('#parent-input').val() + unless /[\w\.]+@\w+\.\w+/.test email + $('#parent-input').parent().addClass('has-error') + $('#parent-email-validator').show() + return false + + request = @supermodel.addRequestResource 'send_one_time_email', { + url: '/db/user/-/send_one_time_email' + data: {email: email, type: 'subscribe modal parent'} + method: 'POST' + }, 0 + request.load() + + $('#email-parent-form').hide() + $('#email-parent-complete').show() + false + onClickPurchaseButton: (e) -> @playSound 'menu-button-click' return @openModalView new AuthModal() if me.get('anonymous') diff --git a/server/analytics/AnalyticsLogEvent.coffee b/server/analytics/AnalyticsLogEvent.coffee index be9a5b461..f5a5b3791 100644 --- a/server/analytics/AnalyticsLogEvent.coffee +++ b/server/analytics/AnalyticsLogEvent.coffee @@ -1,5 +1,7 @@ +log = require 'winston' mongoose = require 'mongoose' plugins = require '../plugins/plugins' +utils = require '../lib/utils' AnalyticsLogEventSchema = new mongoose.Schema({ u: mongoose.Schema.Types.ObjectId @@ -14,4 +16,102 @@ AnalyticsLogEventSchema = new mongoose.Schema({ AnalyticsLogEventSchema.index({event: 1, _id: 1}) +AnalyticsLogEventSchema.statics.logEvent = (user, event, properties) -> + unless user? + log.warn 'No user given to analytics logEvent.' + return + + saveDoc = (eventID, slimProperties) -> + doc = new AnalyticsLogEvent + u: user + e: eventID + p: slimProperties + # TODO: Remove these legacy properties after we stop querying for them (probably 30 days, ~2/16/15) + user: user + event: event + properties: properties + doc.save() + + utils.getAnalyticsStringID event, (eventID) -> + if eventID > 0 + # TODO: properties slimming is pretty ugly + slimProperties = _.cloneDeep properties + if event in ['Clicked Level', 'Show problem alert', 'Started Level', 'Saw Victory', 'Problem alert help clicked', 'Spell palette help clicked'] + delete slimProperties.level if event is 'Saw Victory' + properties.ls = mongoose.Types.ObjectId properties.ls if properties.ls + slimProperties.ls = mongoose.Types.ObjectId slimProperties.ls if slimProperties.ls + if slimProperties.levelID? + # levelID: string => l: string ID + utils.getAnalyticsStringID slimProperties.levelID, (levelStringID) -> + if levelStringID > 0 + delete slimProperties.levelID + slimProperties.l = levelStringID + saveDoc eventID, slimProperties + return + else if event in ['Script Started', 'Script Ended'] + properties.ls = mongoose.Types.ObjectId properties.ls if properties.ls + slimProperties.ls = mongoose.Types.ObjectId slimProperties.ls if slimProperties.ls + if slimProperties.levelID? and slimProperties.label? + # levelID: string => l: string ID + # label: string => lb: string ID + utils.getAnalyticsStringID slimProperties.levelID, (levelStringID) -> + if levelStringID > 0 + delete slimProperties.levelID + slimProperties.l = levelStringID + utils.getAnalyticsStringID slimProperties.label, (labelStringID) -> + if labelStringID > 0 + delete slimProperties.label + slimProperties.lb = labelStringID + saveDoc eventID, slimProperties + return + else if event is 'Heard Sprite' + properties.ls = mongoose.Types.ObjectId properties.ls if properties.ls + slimProperties.ls = mongoose.Types.ObjectId slimProperties.ls if slimProperties.ls + if slimProperties.message? + # message: string => m: string ID + utils.getAnalyticsStringID slimProperties.message, (messageStringID) -> + if messageStringID > 0 + delete slimProperties.message + slimProperties.m = messageStringID + saveDoc eventID, slimProperties + return + else if event in ['Start help video', 'Finish help video'] + properties.ls = mongoose.Types.ObjectId properties.ls if properties.ls + slimProperties.ls = mongoose.Types.ObjectId slimProperties.ls if slimProperties.ls + if slimProperties.level and slimProperties.style? + # level: string => l: string ID + # style: string => s: string ID + utils.getAnalyticsStringID slimProperties.level, (levelStringID) -> + if levelStringID > 0 + delete slimProperties.level + slimProperties.l = levelStringID + utils.getAnalyticsStringID slimProperties.style, (styleStringID) -> + if styleStringID > 0 + delete slimProperties.style + slimProperties.s = styleStringID + saveDoc eventID, slimProperties + return + else if event is 'Show subscription modal' + delete properties.category + delete slimProperties.category + if slimProperties.label? + # label: string => lb: string ID + utils.getAnalyticsStringID slimProperties.label, (labelStringID) -> + if labelStringID > 0 + delete slimProperties.label + slimProperties.lb = labelStringID + if slimProperties.level? + # level: string => l: string ID + utils.getAnalyticsStringID slimProperties.level, (levelStringID) -> + if levelStringID > 0 + delete slimProperties.level + slimProperties.l = levelStringID + saveDoc eventID, slimProperties + return + saveDoc eventID, slimProperties + return + saveDoc eventID, slimProperties + else + log.warn "Unable to get analytics string ID for " + event + module.exports = AnalyticsLogEvent = mongoose.model('analytics.log.event', AnalyticsLogEventSchema) diff --git a/server/analytics/analytics_log_event_handler.coffee b/server/analytics/analytics_log_event_handler.coffee index 91039b731..f31a17234 100644 --- a/server/analytics/analytics_log_event_handler.coffee +++ b/server/analytics/analytics_log_event_handler.coffee @@ -39,102 +39,7 @@ class AnalyticsLogEventHandler extends Handler event = req.query.event or req.body.event properties = req.query.properties or req.body.properties @sendSuccess res # Return request immediately - unless user? - log.warn 'No user given to analytics logEvent.' - return - - saveDoc = (eventID, slimProperties) -> - doc = new AnalyticsLogEvent - u: user - e: eventID - p: slimProperties - # TODO: Remove these legacy properties after we stop querying for them (probably 30 days, ~2/16/15) - user: user - event: event - properties: properties - doc.save() - - utils.getAnalyticsStringID event, (eventID) -> - if eventID > 0 - # TODO: properties slimming is pretty ugly - slimProperties = _.cloneDeep properties - if event in ['Clicked Level', 'Show problem alert', 'Started Level', 'Saw Victory', 'Problem alert help clicked', 'Spell palette help clicked'] - delete slimProperties.level if event is 'Saw Victory' - properties.ls = mongoose.Types.ObjectId properties.ls if properties.ls - slimProperties.ls = mongoose.Types.ObjectId slimProperties.ls if slimProperties.ls - if slimProperties.levelID? - # levelID: string => l: string ID - utils.getAnalyticsStringID slimProperties.levelID, (levelStringID) -> - if levelStringID > 0 - delete slimProperties.levelID - slimProperties.l = levelStringID - saveDoc eventID, slimProperties - return - else if event in ['Script Started', 'Script Ended'] - properties.ls = mongoose.Types.ObjectId properties.ls if properties.ls - slimProperties.ls = mongoose.Types.ObjectId slimProperties.ls if slimProperties.ls - if slimProperties.levelID? and slimProperties.label? - # levelID: string => l: string ID - # label: string => lb: string ID - utils.getAnalyticsStringID slimProperties.levelID, (levelStringID) -> - if levelStringID > 0 - delete slimProperties.levelID - slimProperties.l = levelStringID - utils.getAnalyticsStringID slimProperties.label, (labelStringID) -> - if labelStringID > 0 - delete slimProperties.label - slimProperties.lb = labelStringID - saveDoc eventID, slimProperties - return - else if event is 'Heard Sprite' - properties.ls = mongoose.Types.ObjectId properties.ls if properties.ls - slimProperties.ls = mongoose.Types.ObjectId slimProperties.ls if slimProperties.ls - if slimProperties.message? - # message: string => m: string ID - utils.getAnalyticsStringID slimProperties.message, (messageStringID) -> - if messageStringID > 0 - delete slimProperties.message - slimProperties.m = messageStringID - saveDoc eventID, slimProperties - return - else if event in ['Start help video', 'Finish help video'] - properties.ls = mongoose.Types.ObjectId properties.ls if properties.ls - slimProperties.ls = mongoose.Types.ObjectId slimProperties.ls if slimProperties.ls - if slimProperties.level and slimProperties.style? - # level: string => l: string ID - # style: string => s: string ID - utils.getAnalyticsStringID slimProperties.level, (levelStringID) -> - if levelStringID > 0 - delete slimProperties.level - slimProperties.l = levelStringID - utils.getAnalyticsStringID slimProperties.style, (styleStringID) -> - if styleStringID > 0 - delete slimProperties.style - slimProperties.s = styleStringID - saveDoc eventID, slimProperties - return - else if event is 'Show subscription modal' - delete properties.category - delete slimProperties.category - if slimProperties.label? - # label: string => lb: string ID - utils.getAnalyticsStringID slimProperties.label, (labelStringID) -> - if labelStringID > 0 - delete slimProperties.label - slimProperties.lb = labelStringID - if slimProperties.level? - # level: string => l: string ID - utils.getAnalyticsStringID slimProperties.level, (levelStringID) -> - if levelStringID > 0 - delete slimProperties.level - slimProperties.l = levelStringID - saveDoc eventID, slimProperties - return - saveDoc eventID, slimProperties - return - saveDoc eventID, slimProperties - else - log.warn "Unable to get analytics string ID for " + event + AnalyticsLogEvent.logEvent user, event, properties getLevelCompletionsBySlug: (req, res) -> # Returns an array of per-day level starts and finishes @@ -204,7 +109,7 @@ class AnalyticsLogEventHandler extends Handler for level of levelDateMap completions[level] = [] for created, item of levelDateMap[level] - completions[level].push + completions[level].push level: level created: created started: Object.keys(item.started).length @@ -382,7 +287,7 @@ class AnalyticsLogEventHandler extends Handler getUserEventData campaigns getCampaignData = () => - # Get campaign data + # Get campaign data # Output: # campaigns - per-campaign dictionary of ordered levelIDs # campaignLevelIDs - dictionary of all campaign levelIDs diff --git a/server/sendwithus.coffee b/server/sendwithus.coffee index ff9cdf8b4..d8411b622 100644 --- a/server/sendwithus.coffee +++ b/server/sendwithus.coffee @@ -10,6 +10,7 @@ module.exports.api = new sendwithusAPI swuAPIKey, debug if config.unittest module.exports.api.send = -> module.exports.templates = + parent_subscribe_email: 'tem_2APERafogvwKhmcnouigud' welcome_email: 'utnGaBHuSU4Hmsi7qrAypU' ladder_update_email: 'JzaZxf39A4cKMxpPZUfWy4' patch_created: 'tem_xhxuNosLALsizTNojBjNcL' diff --git a/server/users/user_handler.coffee b/server/users/user_handler.coffee index 2dd8d2fd2..c070c395e 100644 --- a/server/users/user_handler.coffee +++ b/server/users/user_handler.coffee @@ -9,6 +9,7 @@ errors = require '../commons/errors' async = require 'async' log = require 'winston' moment = require 'moment' +AnalyticsLogEvent = require '../analytics/AnalyticsLogEvent' LevelSession = require '../levels/sessions/LevelSession' LevelSessionHandler = require '../levels/sessions/level_session_handler' SubscriptionHandler = require '../payments/subscription_handler' @@ -17,6 +18,7 @@ EarnedAchievement = require '../achievements/EarnedAchievement' UserRemark = require './remarks/UserRemark' {isID} = require '../lib/utils' hipchat = require '../hipchat' +sendwithus = require '../sendwithus' serverProperties = ['passwordHash', 'emailLower', 'nameLower', 'passwordReset', 'lastIP'] candidateProperties = [ @@ -232,6 +234,7 @@ UserHandler = class UserHandler extends Handler return @getRemark(req, res, args[0]) if args[1] is 'remark' return @searchForUser(req, res) if args[1] is 'admin_search' return @getStripeInfo(req, res, args[0]) if args[1] is 'stripe' + return @sendOneTimeEmail(req, res, args[0]) if args[1] is 'send_one_time_email' return @sendNotFoundError(res) super(arguments...) @@ -244,6 +247,35 @@ UserHandler = class UserHandler extends Handler return @sendDatabaseError(res, err) if err @sendSuccess(res, JSON.stringify(customer, null, '\t')) + sendOneTimeEmail: (req, res) -> + # TODO: should this API be somewhere else? + # TODO: hipchat tower success message shows up as a misleading PaperTrail error message + return @sendForbiddenError(res) unless req.user + email = req.query.email or req.body.email + type = req.query.type or req.body.type + return @sendBadInputError res, 'No email given.' unless email? + return @sendBadInputError res, 'No type given.' unless type? + + return @sendBadInputError res, "Unknown one-time email type #{type}" unless type is 'subscribe modal parent' + + emailParams = + email_id: sendwithus.templates.parent_subscribe_email + recipient: + address: email + email_data: + name: req.user.get('name') or '' + sendwithus.api.send emailParams, (err, result) => + if err + log.error "sendwithus one-time email error: #{err}, result: #{result}" + return @sendError res, 500, 'send mail failed.' + req.user.update {$push: {"emails.oneTimes": {type: type, email: email, sent: new Date()}}}, (err) => + return @sendDatabaseError(res, err) if err + req.user.save (err) => + return @sendDatabaseError(res, err) if err + @sendSuccess(res, {result: 'success'}) + hipchat.sendTowerHipChatMessage "#{req.user.get('name') or req.user.get('email')} just submitted subscribe modal parent email #{email}." + AnalyticsLogEvent.logEvent req.user, 'Sent one time email', email: email, type: type + agreeToCLA: (req, res) -> return @sendForbiddenError(res) unless req.user doc =