Update subscribe modal with parent help button

This commit is contained in:
Matt Lott 2015-02-04 13:54:35 -08:00
parent 35c04974dd
commit c842f45786
10 changed files with 350 additions and 143 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View file

@ -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 <strong>new heroes</strong> with unique skills!"
feature3: "30+ bonus levels"
feature4: "<strong>3500 bonus gems</strong> 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."

View file

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

View file

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

View file

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

View file

@ -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 += '<button type="button" class="close" onclick="$(&#39;.parent-button&#39;).popover(&#39;hide&#39;);">&times;</button>'
popoverContent = "<div id='email-parent-form'>"
popoverContent += "<p>#{$.i18n.t('subscribe.parent_email_description')}</p>"
popoverContent += "<form>"
popoverContent += " <div class='form-group'>"
popoverContent += " <label>#{$.i18n.t('subscribe.parent_email_input_label')}</label>"
popoverContent += " <input id='parent-input' type='email' class='form-control' placeholder='#{$.i18n.t('subscribe.parent_email_input_placeholder')}'/>"
popoverContent += " <div id='parent-email-validator' class='email_invalid'>#{$.i18n.t('subscribe.parent_email_input_invalid')}</div>"
popoverContent += " </div>"
popoverContent += " <button id='parent-send' type='submit' class='btn btn-default'>#{$.i18n.t('subscribe.parent_email_send')}</button>"
popoverContent += "</form>"
popoverContent += "</div>"
popoverContent += "<div id='email-parent-complete'>"
popoverContent += " <p>#{$.i18n.t('subscribe.parent_email_sent')}</p>"
popoverContent += " <button type='button' onclick='$(&#39;.parent-button&#39;).popover(&#39;hide&#39;);'>#{$.i18n.t('modal.close')}</button>"
popoverContent += "</div>"
@$el.find('.parent-button').popover(
animation: true
html: true
placement: 'top'
trigger: 'click'
title: popoverTitle
content: popoverContent
container: @$el
).on 'shown.bs.popover', =>
application.tracker?.trackEvent 'Subscription ask parent button click', {}
setupParentInfoPopover: ->
popoverTitle = $.i18n.t 'subscribe.parents_title'
popoverContent = "<p>" + $.i18n.t('subscribe.parents_blurb1') + "</p>"
popoverContent += "<p>" + $.i18n.t('subscribe.parents_blurb2') + "</p>"
@ -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')

View file

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

View file

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

View file

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

View file

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