This commit is contained in:
George Saines 2014-12-03 10:53:52 -08:00
commit 103d1035a2
31 changed files with 869 additions and 184 deletions

Binary file not shown.

After

(image error) Size: 247 KiB

View file

@ -9,7 +9,7 @@
<meta name="viewport" content="width=1024">
<title>CodeCombat - Learn how to code by playing a game</title>
<meta name="description" content="Learn programming with a multiplayer live coding strategy game for beginners. Learn Python or JavaScript as you defeat ogres, solve mazes, and level up. Free, open source HTML5 game!">
<meta name="description" content="Learn programming with a multiplayer live coding strategy game for beginners. Learn Python or JavaScript as you defeat ogres, solve mazes, and level up. Open source HTML5 game!">
<meta property="og:title" content="CodeCombat: Learn to Code by Playing a Game">
<meta property="og:url" content="http://codecombat.com">
@ -22,7 +22,7 @@
<meta name="twitter:url" content="http://codecombat.com">
<meta name="twitter:site" content="CodeCombat">
<meta name="twitter:image:src" content="http://codecombat.com/images/pages/base/logo_square_250.png">
<meta name="twitter:description" content="Learn programming with a multiplayer live coding strategy game for beginners. Learn Python or JavaScript as you defeat ogres, solve mazes, and level up. Free, open source HTML5 game!">
<meta name="twitter:description" content="Learn programming with a multiplayer live coding strategy game for beginners. Learn Python or JavaScript as you defeat ogres, solve mazes, and level up. Open source HTML5 game!">
<link href="https://plus.google.com/115285980638641924488" rel="publisher" />
<link rel="shortcut icon" href="/images/favicon.ico">

View file

@ -4,6 +4,7 @@
no_ie: "CodeCombat does not run in Internet Explorer 8 or older. Sorry!" # Warning that only shows up in IE8 and older
no_mobile: "CodeCombat wasn't designed for mobile devices and may not work!" # Warning that shows up on mobile devices
play: "Play" # The big play button that just starts playing a level
try_it: "Try It" # Alternate wording for Play button
old_browser: "Uh oh, your browser is too old to run CodeCombat. Sorry!" # Warning that shows up on really old Firefox/Chrome/Safari
old_browser_suffix: "You can try anyway, but it probably won't work."
ipad_browser: "Bad news: CodeCombat doesn't run on iPad in the browser. Good news: our native iPad app is awaiting Apple approval."
@ -332,6 +333,18 @@
prompt_body: "Do you want to get more?"
prompt_button: "Enter Shop"
subscribe:
subscribe_title: "Subscribe"
levels: "25 more levels, with 5 new levels every week!"
heroes: "Unlock more heroes, including wizards and rangers!"
gems: "Subscribers get 3500 bonus gems per month!"
items: "Unlock the coding power of 275 new items!"
parents: "Parents"
parents_title: "Your child should learn to code."
parents_blurb: "Invest in your child's education by teaching her to program with CodeCombat. It's just $9.99/mo, and that comes with personal email support, cancellation with a click, and a 100% money-back guarantee."
subscribe_button: "$9.99/mo - Subscribe"
stripe_description: "Monthly Subscription"
choose_hero:
choose_hero: "Choose Your Hero"
programming_language: "Programming Language"
@ -886,6 +899,7 @@
legal:
page_title: "Legal"
opensource_intro: "CodeCombat is free to play and completely open source."
opensource_intro_2: "CodeCombat completely open source."
opensource_description_prefix: "Check out "
github_url: "our GitHub"
opensource_description_center: "and help out if you like! CodeCombat is built on dozens of open source projects, and we love them. See "
@ -894,7 +908,7 @@
practices_title: "Respectful Best Practices"
practices_description: "These are our promises to you, the player, in slightly less legalese."
privacy_title: "Privacy"
privacy_description: "We will not sell any of your personal information. We intend to make money through recruitment eventually, but rest assured we will not distribute your personal information to interested companies without your explicit consent."
privacy_description_2: "We will not sell any of your personal information."
security_title: "Security"
security_description: "We strive to keep your personal information safe. As an open source project, our site is freely open to anyone to review and improve our security systems."
email_title: "Email"
@ -903,12 +917,7 @@
email_description_suffix: "or through links in the emails we send, you can change your preferences and easily unsubscribe at any time."
cost_title: "Cost"
cost_description: "Currently, CodeCombat is 100% free! One of our main goals is to keep it that way, so that as many people can play as possible, regardless of place in life. If the sky darkens, we might have to charge subscriptions or for some content, but we'd rather not. With any luck, we'll be able to sustain the company with:"
recruitment_title: "Recruitment"
recruitment_description_prefix: "Here on CodeCombat, you're going to become a powerful wizardnot just in the game, but also in real life."
url_hire_programmers: "No one can hire programmers fast enough"
recruitment_description_suffix: "so once you've sharpened your skills and if you agree, we will demo your best coding accomplishments to the thousands of employers who are drooling for the chance to hire you. They pay us a little, they pay you"
recruitment_description_italic: "a lot"
recruitment_description_ending: "the site remains free and everybody's happy. That's the plan."
cost_description_2: "CodeCombat is free to play in the dungeon campaign, with a $9.99/mo subscription for access to later campaigns and 3500 bonus gems per month. You can cancel with a click, and we offer a 100% money-back guarantee."
copyrights_title: "Copyrights and Licenses"
contributor_title: "Contributor License Agreement"
contributor_description_prefix: "All contributions, both on the site and on our GitHub repository, are subject to our"

View file

@ -104,7 +104,7 @@ module.exports = class User extends CocoModel
getBranchingGroup: ->
return @branchingGroup if @branchingGroup
return 'no-practice' # A/B test paused for school testing
return 'all-practice' # A/B test paused for Hour of Code
group = me.get('testGroupNumber') % 4
@branchingGroup = switch group
when 0 then 'no-practice'

View file

@ -20,6 +20,7 @@ PaymentSchema = c.object({title: 'Payment', required: []}, {
timestamp: { type: 'integer', description: 'Unique identifier provided by the client, to guard against duplicate payments.' }
chargeID: { type: 'string' }
customerID: { type: 'string' }
invoiceID: { type: 'string' }
})
})

View file

@ -275,8 +275,9 @@ _.extend UserSchema.properties,
stripe: c.object {}, {
customerID: { type: 'string' }
subscription: { enum: ['basic'] }
token: { type: 'string' }
planID: { enum: ['basic'] }
subscriptionID: { type: 'string' }
token: { type: 'string' }
}
c.extendBasicProperties UserSchema, 'user'

View file

@ -1,30 +0,0 @@
@import "app/styles/mixins"
@import "app/styles/bootstrap/variables"
#front-view
h1
text-align: center
margin-top: 0
.platform-choices
a
text-align: center
.panel
@include transition(background-color 0.5s ease)
&:hover
text-decoration: none
.panel
background-color: rgb(230, 230, 255)
.platform-ios
img
transform: scaleY(-1)
@media only screen and (max-width: 800px)
#front-view
#site-slogan
font-size: 30px
margin-bottom: 30px

View file

@ -485,15 +485,15 @@ $itemSlotGridHeight: 51px
position: absolute
z-index: 12
&.male
left: 65px
&.female
left: 80px
bottom: 31px
&.Ranger
left: -7px
&.female
left: 80px
&.male
left: 65px
bottom: 31px
&.Ranger
@ -542,6 +542,13 @@ $itemSlotGridHeight: 51px
bottom: 63px
@include rotate(-15deg)
&.right-hand.Ranger
&.female
left: -7px
&.male
left: -7px
&.left-hand
z-index: 17

View file

@ -115,7 +115,7 @@ html.no-borderimage #cast-button-view
border: 0
&.submit-button, &.done-button
background-image: url(/images/level/code_toolbar_submit_button_active_pressed.png)
background-image: url(/images/level/code_toolbar_submit_button_active.png)
border: 0
&:active

View file

@ -22,7 +22,6 @@
top: 242px
width: 720px
height: 140px
index: 1
.product
width: 228px

View file

@ -28,7 +28,7 @@ $heroCanvasHeight: 265px
left: 154px
top: 25px
margin: 0
width: 350px
width: 450px
text-align: center
color: rgb(254,188,68)
font-size: 38px

View file

@ -0,0 +1,145 @@
@import "app/styles/mixins"
@import "app/styles/bootstrap/variables"
#subscribe-modal
//- Clear modal defaults
.modal-dialog
margin: 60px auto 0 auto
padding: 0
width: 746px
height: 520px
background: none
//- Background
#subscribe-background
position: absolute
top: -61px
left: 0px
//- Header
h1
position: absolute
left: 150px
top: 25px
margin: 0
width: 410px
text-align: center
color: rgb(254,188,68)
font-size: 38px
text-shadow: black 4px 4px 0, black -4px -4px 0, black 4px -4px 0, black -4px 4px 0, black 4px 0px 0, black 0px -4px 0, black -4px 0px 0, black 0px 4px 0, black 6px 6px 6px
font-variant: normal
text-transform: uppercase
//- Close modal button
#close-modal
position: absolute
left: 568px
top: 17px
width: 60px
height: 60px
color: white
text-align: center
font-size: 30px
padding-top: 15px
cursor: pointer
@include rotate(-3deg)
&: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: 'Open Sans Condensed'
font-size: 18px
.point
width: 150px
overflow: none
float: left
text-align: left
margin-right: 10px
#parents-info
position: absolute
right: 7px
top: 56px
text-decoration: underline
cursor: pointer
.popover
z-index: 1050
h3
background: transparent
border: 0
font-size: 30px
color: black
//- Purchase button
.purchase-button
position: absolute
left: 73px
width: 600px
height: 70px
top: 430px
font-size: 32px
line-height: 42px
border-image: url(/images/level/code_toolbar_submit_button_active.png) 14 20 20 20 fill round
border-width: 14px 20px 20px 20px
color: darken(white, 5%)
span
pointer-events: none
&:hover
border-image: url(/images/level/code_toolbar_submit_button_zazz.png) 14 20 20 20 fill round
color: white
&:active
border-image: url(/images/level/code_toolbar_submit_button_zazz_pressed.png) 14 20 20 20 fill round
padding: 2px 0 0 2px
color: white
//- Errors
.alert
position: absolute
left: 10%
width: 80%
top: 20px
border: 5px solid gray
html.no-borderimage #subscribe-modal
.purchase-button
border: 0
background-image: url(/images/level/code_toolbar_submit_button_active.png)
background-size: 100% 100%
padding: 7px 10px 10px 10px
&:hover
background-image: url(/images/level/code_toolbar_submit_button_zazz.png)
border: 0
&:active
background-image: url(/images/level/code_toolbar_submit_button_zazz_pressed.png)
padding: 9px 8px 8px 12px
border: 0

View file

@ -1,28 +0,0 @@
extends /templates/base
block content
.page-header
h1#site-slogan
span(data-i18n="home.slogan") Learn to Code by Playing a Game
small.spl.spr -
small free!
.row.platform-choices
.col-xs-6.platform-ios
a(href="#")
.panel.panel-default
.panel-body
h1 Play on iPad
img.img-responsive(src="/images/pages/front/play_web.jpg")
p.lead Get the app!
.col-xs-6.platform-web
a(href="/play-hero")
.panel.panel-default
.panel-body
h1 Play on web
img.img-responsive(src="/images/pages/front/play_web.jpg")
p.lead Play right now!
.clearfix

View file

@ -1,26 +0,0 @@
extends /templates/base
block content
.page-header
h1#site-slogan
span(data-i18n="home.slogan") Learn to Code by Playing a Game
small.spl.spr -
small free!
.row.platform-choices
.col-xs-6.platform-ios
a(href="#")
.panel.panel-default
.panel-body
h1 Play on iPad
em - get the app!
.col-xs-6.platform-web
a(href="/play")
.panel.panel-default
.panel-body
h1 Play on web
em - play right now!
.clearfix

View file

@ -7,8 +7,8 @@ block content
hr
p(data-i18n="legal.opensource_intro").lead
| CodeCombat is free to play and completely open source.
p(data-i18n="legal.opensource_intro_2").lead
| CodeCombat is completely open source.
p
span(data-i18n="legal.opensource_description_prefix")
| Check out
@ -32,11 +32,8 @@ block content
| These are our promises to you, the player, in slightly less legalese.
h4(data-i18n="legal.privacy_title")
| Privacy
p(data-i18n="legal.privacy_description")
p(data-i18n="legal.privacy_description_2")
| We will not sell any of your personal information.
| We intend to make money through recruitment eventually,
| but rest assured we will not distribute your personal
| information to interested companies without your explicit consent.
h4(data-i18n="legal.security_title")
| Security
p(data-i18n="legal.security_description")
@ -57,32 +54,8 @@ block content
| you can change your preferences and easily unsubscribe at any time.
h4(data-i18n="legal.cost_title")
| Cost
p(data-i18n="legal.cost_description")
| Currently, CodeCombat is 100% free! One of our main goals is to keep
| it that way, so that as many people can play as possible,
| regardless of place in life. If the sky darkens,
| we might have to charge subscriptions or for some content,
| but we'd rather not. With any luck, we'll be able to sustain the company with:
h4(data-i18n="legal.recruitment_title")
| Recruitment
span(data-i18n="legal.recruitment_description_prefix")
| Here on CodeCombat, you're going to become a powerful wizardnot
| just in the game, but also in real life.
span
a(href="https://code.org/stats", data-i18n="legal.url_hire_programmers")
| No one can hire programmers fast enough
span ,
span(data-i18n="legal.recruitment_description_suffix")
| so once you've sharpened your skills and if you agree,
| we will demo your best coding accomplishments to the thousands of
| employers who are drooling for the chance to hire you.
| They pay us a little, they pay you
span
i(data-i18n="legal.recruitment_description_italic")
| a lot
span ,
span(data-i18n="legal.recruitment_description_ending")
| the site remains free and everybody's happy. That's the plan.
p(data-i18n="legal.cost_description_2")
| CodeCombat is free to play in the dungeon campaign, with a $9.99/mo subscription for access to later campaigns and 3500 bonus gems per month. You can cancel with a click, and we offer a 100% money-back guarantee.
h2(data-i18n="legal.copyrights_title")
| Copyrights and Licenses

View file

@ -24,4 +24,4 @@ block content
span.spl.spr - #{playCount.sessions}
span(data-i18n="play.players") players
.play-text-container
.overlay-text.play-text(data-i18n="home.play") Play
.overlay-text.play-text= playText

View file

@ -0,0 +1,43 @@
.modal-dialog
.modal-content
if state === 'purchasing'
.alert.alert-info(data-i18n="buy_gems.purchasing")
else if state === 'retrying'
#retrying-alert.alert.alert-danger(data-i18n="buy_gems.retrying")
else
img(src="/images/pages/play/modal/subscribe-background.png")#subscribe-background
h1(data-i18n="subscribe.subscribe_title") Subscribe
div#close-modal
span.glyphicon.glyphicon-remove
#selling-points
#point-levels.point
.blurb(data-i18n="subscribe.levels") 25 more levels, with 5 new levels every week!
#point-heroes.point
.blurb(data-i18n="subscribe.heroes") Unlock more heroes, including wizards and rangers!
#point-gems.point
.blurb(data-i18n="subscribe.gems") Subscribers get 3500 bonus gems per month!
#point-items.point
.blurb(data-i18n="subscribe.items") Unlock the coding power of 275 new items!
#parents-info(data-i18n="subscribe.parents")
button.btn.btn-lg.btn-illustrated.purchase-button(data-i18n="subscribe.subscribe_button")
span $9.99/mo - Subscribe
if state === 'declined'
#declined-alert.alert.alert-danger.alert-dismissible
span(data-i18n="buy_gems.declined")
button.close(type="button" data-dismiss="alert")
span(aria-hidden="true") &times;
if state === 'unknown_error'
#error-alert.alert.alert-danger.alert-dismissible
button.close(type="button" data-dismiss="alert")
span(aria-hidden="true") &times;
p(data-i18n="loading_error.unknown")
p= stateMessage

View file

@ -10,14 +10,9 @@ block content
h3 Preparation
p CodeCombat is free to play and does not require students to sign up. We encourage teachers to
p CodeCombat is free to play through the dungeon campaign and does not require students to sign up. We encourage teachers to
a(href="http://codecombat.com/play") play through the campaign
| to try it out, but the only thing you absolutely need to do to be ready is ensure students have access to a computer or iPad.
p
| The iPad version is not publicly available yet, but you can
a(href="https://www.youtube.com/watch?v=jgF4YuN7RBk&feature=youtu.be") see a preview video here
| .
| to try it out, but the only thing you absolutely need to do to be ready is ensure students have access to a computer.
p It is not necessary for teachers to be comfortable with computer science concepts for students to have fun learning with CodeCombat.
@ -31,34 +26,39 @@ block content
h3 What do we cover?
p There are 12 levels in the Hour of Code tutorial that teach and reinforce 5 specific computer science concepts:
p There are 20 levels in the Hour of Code tutorial that teach and reinforce 6 specific computer science concepts:
ol
li <strong>Formal notation</strong> - builds an understanding of the importance of syntax in programming.
li <strong>Calling methods</strong> - familiarizes students with the syntax of object-oriented method calls.
li <strong>Parameters</strong> - trains how to pass parameters to functions.
li <strong>Strings</strong> - teaches students about string notation and passing strings as parameters.
li <strong>Loops</strong> - develops the abstraction of designing short programs with loops.
p Students may continue past level 12, depending on their speed and interest, to learn two additional concepts in levels 13-20:
ol
li <strong>Conditional logic</strong> - when and how to use if/else to control in-game outcomes.
li <strong>Handling player input</strong> - responding to input events to create a user interface.
li
strong Formal notation
| - builds an understanding of the importance of syntax in programming.
li
strong Calling methods
| - familiarizes students with the syntax of object-oriented method calls.
li
strong Parameters
| - trains how to pass parameters to functions.
li
strong Strings
| - teaches students about string notation and passing strings as parameters.
li
strong Loops
| - develops the abstraction of designing short programs with loops.
li
strong Variables
| - adds the skill of referencing values that change over time.
h3 System Requirements
p Because CodeCombat is a game, it is more intensive for computers to run smoothly than video or written tutorials. We are continually improving performance and expect to have the game running smoothly on older machines by December. That said, here are our suggestions for getting the most out of your Hour of Code experience:
p Because CodeCombat is a game, it is more intensive for computers to run smoothly than video or written tutorials. We have optimized it to run quickly on all modern browsers and on older machines so that everyone can play. That said, here are our suggestions for getting the most out of your Hour of Code experience:
p For mobile players:
ul
li <strong>Use newer iPads if possible.</strong> Older iPads (iPad 2+) will work, but will play the game more slowly. The app requires iOS 8.
li <strong>Allow players to wear headphones/earbuds to hear the audio.</strong> We help players learn through voiceover and sound effects which will make classrooms noisy and distracting.
p For desktop and laptop players:
ul
li <strong>Use newer versions of Chrome or Firefox.</strong> Although CodeCombat will work on browsers as old as IE9, the performance is not as good.
li <strong>Use newer computers.</strong> Older computers, Chromebooks, and netbooks tend to have very few system resources, which makes for a less enjoyable experience.
li <strong>Allow players to wear headphones/earbuds to hear the audio.</strong> We help players learn through voiceover and sound effects which will make classrooms noisy and distracting.
ul
li
strong Use newer versions of Chrome or Firefox.
| Although CodeCombat will work on browsers as old as IE9, the performance is not as good. Chrome is best.
li
strong Use newer computers.
| Older computers, Chromebooks, and netbooks tend to have very few system resources, which makes for a less enjoyable experience. At least 2GB of RAM is required.
li
strong Allow players to wear headphones/earbuds to hear the audio.
| We help players learn through voiceover and sound effects, which will make classrooms noisy and distracting.

View file

@ -32,6 +32,9 @@ module.exports = class HomeView extends RootView
c.explainsHourOfCode = @explainsHourOfCode
c.isMobile = @isMobile()
c.isIPadBrowser = @isIPadBrowser()
c.playText = $.i18n.t('home.try_it', false)
if c.playText is 'home.try_it'
c.playText = $.i18n.t 'home.play' # Temporary fallback for not having many try_it translations yet.
c
onClickBeginnerCampaign: (e) ->

View file

@ -9,6 +9,7 @@ ThangType = require 'models/ThangType'
MusicPlayer = require 'lib/surface/MusicPlayer'
storage = require 'core/storage'
AuthModal = require 'views/core/AuthModal'
SubscribeModal = require 'views/play/modal/SubscribeModal'
trackedHourOfCode = false
@ -77,6 +78,8 @@ module.exports = class WorldMapView extends RootView
$('body').append($('<img src="http://code.org/api/hour/begin_codecombat.png" style="visibility: hidden;">'))
trackedHourOfCode = true
@requiresSubscription = me.isAdmin() and @terrain isnt 'dungeon' and not me.get('stripe')?.subscriptionID
destroy: ->
@setupManager?.destroy()
$(window).off 'resize', @onWindowResize
@ -112,6 +115,8 @@ module.exports = class WorldMapView extends RootView
@fullyRendered = true
@render()
@preloadTopHeroes() unless me.get('heroConfig')?.thangType
if @requiresSubscription
_.delay (=> @openModalView? new SubscribeModal() unless window.currentModal), 2000
getRenderData: (context={}) ->
context = super(context)
@ -145,7 +150,7 @@ module.exports = class WorldMapView extends RootView
@$el.find('.level').tooltip()
@$el.addClass _.string.slugify @terrain
@updateVolume()
unless window.currentModal or not @fullyRendered
unless window.currentModal or not @fullyRendered or @requiresSubscription
@highlightElement '.level.next', delay: 500, duration: 60000, rotation: 0, sides: ['top']
if levelID = @$el.find('.level.next').data('level-id')
@$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show()
@ -182,20 +187,23 @@ module.exports = class WorldMapView extends RootView
e.preventDefault()
e.stopPropagation()
@$levelInfo?.hide()
levelElement = $(e.target).parents('.level')
levelID = levelElement.data('level-id')
campaign = _.find campaigns, id: @terrain
level = _.find campaign.levels, id: levelID
if application.isIPadApp
levelID = $(e.target).parents('.level').data('level-id')
@$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show()
@adjustLevelInfoPosition e
@endHighlight()
else
if $(e.target).attr('disabled')
if @requiresSubscription
@openModalView new SubscribeModal()
else if $(e.target).attr('disabled')
Backbone.Mediator.publish 'router:navigate', route: '/contribute/adventurer'
return
else if $(e.target).parent().hasClass 'locked'
return
else
levelElement = $(e.target).parents('.level')
levelID = levelElement.data('level-id')
@startLevel levelElement
window.tracker?.trackEvent 'Clicked Level', category: 'World Map', levelID: levelID, ['Google Analytics']

View file

@ -321,12 +321,12 @@ module.exports = class SpellView extends CocoView
return false
if e.command.name in ['enter-skip-delimiters', 'Enter', 'Return']
if intersects()
@ace.navigateDown 1
@ace.navigateLineStart()
return false
else if e.command.name in ['Enter', 'Return']
@ace.execCommand 'enter-skip-delimiters'
e.editor.navigateDown 1
e.editor.navigateLineStart()
return false
else if e.command.name in ['Enter', 'Return'] and not e.editor?.completer?.popup?.isOpen
@zatanna?.on?()
return e.editor.execCommand 'enter-skip-delimiters'
@zatanna?.on?()
e.command.exec e.editor, e.args or {}
@ -379,7 +379,7 @@ module.exports = class SpellView extends CocoView
else content
entry =
content: content
meta: 'press tab'
meta: 'press enter'
name: doc.name
tabTrigger: doc.snippets[e.language].tab
if doc.name is 'findNearestEnemy'

View file

@ -57,7 +57,7 @@ module.exports = class BuyGemsModal extends ModalView
Backbone.Mediator.publish 'buy-gems-modal:purchase-initiated', { productID: productID }
else
application.tracker?.trackEvent 'Started purchase', {productID:productID}, ['Google Analytics']
application.tracker?.trackEvent 'Started purchase', { productID: productID }
stripeHandler.open({
description: $.t(product.i18n)
amount: product.amount

View file

@ -0,0 +1,84 @@
ModalView = require 'views/core/ModalView'
template = require 'templates/play/modal/subscribe-modal'
stripeHandler = require 'core/services/stripe'
utils = require 'core/utils'
module.exports = class SubscribeModal extends ModalView
id: 'subscribe-modal'
template: template
plain: true
closesOnClickOutside: false
product:
amount: 999
planID: 'basic'
subscriptions:
'stripe:received-token': 'onStripeReceivedToken'
events:
'click .purchase-button': 'onClickPurchaseButton'
'click #close-modal': 'hide'
constructor: (options) ->
super(options)
@state = 'standby'
getRenderData: ->
c = super()
c.state = @state
c.stateMessage = @stateMessage
return c
afterRender: ->
super()
popoverTitle = $.i18n.t 'subscribe.parents_title'
popoverContent = $.i18n.t 'subscribe.parents_blurb'
@$el.find('#parents-info').popover(
animation: true
html: true
placement: 'top'
trigger: 'hover'
title: popoverTitle
content: popoverContent
container: @$el
).on 'shown.bs.popover', =>
application.tracker?.trackEvent 'Subscription parent hover', {}
onClickPurchaseButton: (e) ->
@playSound 'menu-button-click'
application.tracker?.trackEvent 'Started subscription purchase', {}
stripeHandler.open({
description: $.i18n.t 'subscribe.stripe_description'
amount: @product.amount
})
onStripeReceivedToken: (e) ->
@state = 'purchasing'
@render()
stripe = me.get('stripe') ? {}
stripe.planID = @product.planID
stripe.token = e.token.id
me.set 'stripe', stripe
@listenToOnce me, 'sync', @onSubscriptionSuccess
@listenToOnce me, 'error', @onSubscriptionError
me.save()
onSubscriptionSuccess: ->
application.tracker?.trackEvent 'Finished subscription purchase', {}
@playSound 'victory'
@hide()
onSubscriptionError: (user, response, options) ->
console.error 'We got an error subscribing with Stripe from our server:', response
stripe = me.get('stripe') ? {}
delete stripe.token
delete stripe.planID
xhr = options.xhr
if xhr.status is 402
@state = 'declined'
else
@state = 'unknown_error'
@stateMessage = "#{xhr.status}: #{xhr.responseText}"
@render()

View file

@ -91,3 +91,74 @@ rs0:PRIMARY> levelUserCounts;
"criss-cross" : 1,
}
"""
# With usernames, 3 days instead of 1:
"""
Found 532 users who played more than an hour out of 277828.
>
> print("Levels by number of users completing:");
Levels by number of users completing:
>
> levelUserCounts;
{
"dungeons-of-kithgard" : 524,
"gems-in-the-deep" : 513,
"shadow-guard" : 515,
"kounter-kithwise" : 229,
"forgetful-gemsmith" : 520,
"true-names" : 516,
"favorable-odds" : 216,
"the-raised-sword" : 504,
"haunted-kithmaze" : 494,
"the-second-kithmaze" : 472,
"dread-door" : 475,
"known-enemy" : 463,
"master-of-names" : 440,
"lowly-kithmen" : 403,
"closing-the-distance" : 393,
"tactical-strike" : 139,
"the-final-kithmaze" : 321,
"the-gauntlet" : 113,
"kithgard-gates" : 253,
"defense-of-plainswood" : 236,
"descending-further" : 200,
"winding-trail" : 196,
"endangered-burl" : 133,
"village-guard" : 118,
"thornbush-farm" : 89,
"back-to-back" : 77,
"ogre-encampment" : 66,
"woodland-cleaver" : 56,
"shield-rush" : 32,
"peasant-protection" : 30,
"munchkin-swarm" : 28,
"munchkin-harvest" : 9,
"swift-dagger" : 3,
"shrapnel" : 1,
"arcane-ally" : 10,
"bonemender" : 4,
"coinucopia" : 21,
"copper-meadows" : 17,
"drop-the-flag" : 11,
"deadly-pursuit" : 13,
"rich-forager" : 6,
"undefined" : 8,
"dungeon-arena-tutorial" : 8,
"dungeon-arena" : 8,
"grab-the-mushroom" : 6,
"gold-rush" : 6,
"criss-cross" : 4,
"rescue-mission" : 3,
"touch-of-death" : 2,
"taunt-the-guards" : 1,
"taunt" : 1,
"sky-span" : 1,
"greed" : 1,
"dungeon-battle" : 1,
"drink-me" : 1,
"cowardly-taunt" : 1,
"bubble-sort-bootcamp-battle" : 1,
"break-the-prison" : 1
}
"""

View file

@ -24,10 +24,10 @@ module.exports = class Handler
constructor: ->
# TODO The second 'or' is for backward compatibility only is for backward compatibility only
@privateProperties = @modelClass.privateProperties or @privateProperties or []
@editableProperties = @modelClass.editableProperties or @editableProperties or []
@postEditableProperties = @modelClass.postEditableProperties or @postEditableProperties or []
@jsonSchema = @modelClass.jsonSchema or @jsonSchema or {}
@privateProperties = @modelClass?.privateProperties or @privateProperties or []
@editableProperties = @modelClass?.editableProperties or @editableProperties or []
@postEditableProperties = @modelClass?.postEditableProperties or @postEditableProperties or []
@jsonSchema = @modelClass?.jsonSchema or @jsonSchema or {}
# subclasses should override these methods
hasAccess: (req) -> true
@ -336,6 +336,7 @@ module.exports = class Handler
return @sendNotFoundError(res) unless document?
return @sendForbiddenError(res) unless @hasAccessToDocument(req, document)
@doWaterfallChecks req, document, (err, document) =>
return if err is true
return @sendError(res, err.code, err.res) if err
@saveChangesToDocument req, document, (err) =>
return @sendBadInputError(res, err.errors) if err?.valid is false

View file

@ -30,6 +30,7 @@ module.exports.routes =
'routes/sprites'
'routes/queue'
'routes/stacklead'
'routes/stripe'
]
mongoose = require 'mongoose'

View file

@ -0,0 +1,141 @@
# Not paired with a document in the DB, just handles coordinating between
# the stripe property in the user with what's being stored in Stripe.
Handler = require '../commons/Handler'
config = require '../../server_config'
stripe = require('stripe')(config.stripe.secretKey)
subscriptions = {
basic: {
gems: 3500
}
}
class SubscriptionHandler extends Handler
logSubscriptionError: (req, msg) ->
console.warn "Subscription Error: #{req.user.get('slug')} (#{req.user._id}): '#{msg}'"
subscribeUser: (req, user, done) ->
stripeToken = req.body.stripe?.token
extantCustomerID = user.get('stripe')?.customerID
if not (stripeToken or extantCustomerID)
@logSubscriptionError(req, 'Missing stripe token or customer ID.')
return done({res: 'Missing stripe token or customer ID.', code: 422})
if stripeToken
stripe.customers.create({
card: stripeToken
description: req.user._id + ''
}).then(((customer) =>
stripeInfo = _.cloneDeep(req.user.get('stripe') ? {})
stripeInfo.customerID = customer.id
req.user.set('stripe', stripeInfo)
req.user.save((err) =>
if err
@logSubscriptionError(req, 'Stripe customer id save db error. '+err)
return done({res: 'Database error.', code: 500})
@checkForExistingSubscription(req, user, customer, done)
)
),
(err) =>
if err.type in ['StripeCardError', 'StripeInvalidRequestError']
done({res: 'Card error', code: 402})
else
@logSubscriptionError(req, 'Stripe customer creation error. '+err)
return done({res: 'Database error.', code: 500})
)
else
stripe.customers.retrieve(extantCustomerID, (err, customer) =>
if err
@logSubscriptionError(req, 'Stripe customer creation error. '+err)
return done({res: 'Database error.', code: 500})
else if not customer
# TODO: what actually happens when you try to retrieve a customer and it DNE?
@logSubscriptionError(req, 'Stripe customer id is missing! '+err)
stripeInfo = _.cloneDeep(req.user.get('stripe') ? {})
delete stripeInfo.customerID
req.user.set('stripe', stripeInfo)
req.user.save (err) =>
if err
@logSubscriptionError(req, 'Stripe customer id delete db error. '+err)
return done({res: 'Database error.', code: 500})
@subscribeUser(req, done)
else
@checkForExistingSubscription(req, user, customer, done)
)
checkForExistingSubscription: (req, user, customer, done) ->
if subscription = customer.subscriptions?.data?[0]
if subscription.cancel_at_period_end
# Things are a little tricky here. Can't re-enable a cancelled subscription,
# so it needs to be deleted, but also don't want to charge for the new subscription immediately.
# So delete the cancelled subscription (no at_period_end given here) and give the new
# subscription a trial period that ends when the cancelled subscription would have ended.
stripe.customers.cancelSubscription subscription.customer, subscription.id, (err) =>
if err
@logSubscriptionError(req, 'Stripe cancel subscription error. '+err)
return done({res: 'Database error.', code: 500})
options = { plan: 'basic', trial_end: subscription.current_period_end }
stripe.customers.update req.user.get('stripe').customerID, options, (err, customer) =>
if err
@logSubscriptionError(req, 'Stripe customer plan setting error. '+err)
return done({res: 'Database error.', code: 500})
@updateUser(req, user, customer.subscriptions.data[0], false, done)
else
# can skip creating the subscription
return @updateUser(req, user, customer.subscriptions.data[0], false, done)
else
stripe.customers.update req.user.get('stripe').customerID, { plan: 'basic' }, (err, customer) =>
if err
@logSubscriptionError(req, 'Stripe customer plan setting error. '+err)
return done({res: 'Database error.', code: 500})
@updateUser(req, user, customer.subscriptions.data[0], true, done)
updateUser: (req, user, subscription, increment, done) ->
stripeInfo = _.cloneDeep(user.get('stripe') ? {})
stripeInfo.planID = 'basic'
stripeInfo.subscriptionID = subscription.id
stripeInfo.customerID = subscription.customer
req.body.stripe = stripeInfo # to make sure things work for admins, who are mad with power
user.set('stripe', stripeInfo)
if increment
purchased = _.clone(user.get('purchased'))
purchased ?= {}
purchased.gems ?= 0
purchased.gems += subscriptions.basic.gems # TODO: Put actual subscription amount here
user.set('purchased', purchased)
user.save (err) =>
if err
@logSubscriptionError(req, 'Stripe user plan saving error. '+err)
return done({res: 'Database error.', code: 500})
return done()
unsubscribeUser: (req, user, done) ->
stripeInfo = _.cloneDeep(user.get('stripe'))
stripe.customers.cancelSubscription stripeInfo.customerID, stripeInfo.subscriptionID, { at_period_end: true }, (err) =>
if err
@logSubscriptionError(req, 'Stripe cancel subscription error. '+err)
return done({res: 'Database error.', code: 500})
delete stripeInfo.planID
user.set('stripe', stripeInfo)
req.body.stripe = stripeInfo
user.save (err) =>
if err
@logSubscriptionError(req, 'User save unsubscribe error. '+err)
return done({res: 'Database error.', code: 500})
return done()
module.exports = new SubscriptionHandler()

View file

@ -0,0 +1,64 @@
config = require '../../server_config'
stripe = require('stripe')(config.stripe.secretKey)
User = require '../users/User'
Payment = require '../payments/Payment'
module.exports.setup = (app) ->
app.post '/stripe/webhook', (req, res, next) ->
if req.body.type is 'invoice.payment_succeeded' # if they actually paid, give em some gems
invoiceID = req.body.data.object.id
stripe.invoices.retrieve invoiceID, (err, invoice) =>
return res.send(500, '') if err
return res.send(200, '') unless invoice.total # invoices made when trialing, probably given for people who resubscribe after unsubscribing
stripe.customers.retrieve invoice.customer, (err, customer) =>
return res.send(500, '') if err
userID = customer.description
User.findById userID, (err, user) =>
return res.send(500, '') if err
return res.send(200) if not user # just for the sake of testing...
Payment.findOne {'stripe.invoiceID': invoiceID}, (err, payment) =>
return res.send(200, '') if payment
payment = new Payment({
'purchaser': user._id
'recipient': user._id
'created': new Date().toISOString()
'service': 'stripe'
'amount': invoice.total
'gems': 3500
'stripe': {
customerID: invoice.customer
invoiceID: invoice.id
subscriptionID: 'basic'
}
})
payment.save (err) =>
return res.send(500, '') if err
Payment.find({recipient: user._id}).select('gems').exec (err, payments) ->
gems = _.reduce payments, ((sum, p) -> sum + p.get('gems')), 0
purchased = _.clone(user.get('purchased'))
purchased ?= {}
purchased.gems = gems
user.set('purchased', purchased)
user.save (err) ->
return res.send(500, '') if err
return res.send(201, '')
else if req.body.type is 'customer.subscription.deleted'
User.findOne {'stripe.subscriptionID': req.body.data.object.id}, (err, user) ->
return res.send(200, '') unless user
stripeInfo = _.cloneDeep(user.get('stripe'))
delete stripeInfo.planID
delete stripeInfo.subscriptionID
user.set('stripe', stripeInfo)
user.save (err) =>
return res.send(500, '') if err
return res.send(200, '')
else # ignore all other notifications
return res.send(200, '')

View file

@ -204,6 +204,7 @@ UserSchema.statics.editableProperties = [
]
UserSchema.plugin plugins.NamedPlugin
UserSchema.index({'stripe.subscriptionID':1}, {unique: true, sparse: true})
module.exports = User = mongoose.model('User', UserSchema)

View file

@ -11,6 +11,7 @@ log = require 'winston'
moment = require 'moment'
LevelSession = require '../levels/sessions/LevelSession'
LevelSessionHandler = require '../levels/sessions/level_session_handler'
SubscriptionHandler = require '../payments/subscription_handler'
EarnedAchievement = require '../achievements/EarnedAchievement'
UserRemark = require './remarks/UserRemark'
{isID} = require '../lib/utils'
@ -105,6 +106,23 @@ UserHandler = class UserHandler extends Handler
return callback({res: r, code: 409}) if otherUser
user.set('name', req.body.name)
callback(null, req, user)
# Subscription setting
(req, user, callback) ->
hasPlan = user.get('stripe')?.planID?
wantsPlan = req.body.stripe?.planID?
return callback(null, req, user) if hasPlan is wantsPlan
if wantsPlan and not hasPlan
SubscriptionHandler.subscribeUser(req, user, (err) ->
return callback(err) if err
return callback(null, req, user)
)
else if hasPlan and not wantsPlan
SubscriptionHandler.unsubscribeUser(req, user, (err) ->
return callback(err) if err
return callback(null, req, user)
)
]
getById: (req, res, id) ->

View file

@ -0,0 +1,199 @@
config = require '../../../server_config'
require '../common'
# sample data that comes in through the webhook when you subscribe
invoiceChargeSampleEvent = {
id: 'evt_155TBeKaReE7xLUdrKM72O5R',
created: 1417574898,
livemode: false,
type: 'invoice.payment_succeeded',
data: {
object: {
date: 1417574897,
id: 'in_155TBdKaReE7xLUdv8z8ipWl',
period_start: 1417574897,
period_end: 1417574897,
lines: {},
subtotal: 999,
total: 999,
customer: 'cus_5Fz9MVWP2bDPGV',
object: 'invoice',
attempted: true,
closed: true,
forgiven: false,
paid: true,
livemode: false,
attempt_count: 1,
amount_due: 999,
currency: 'usd',
starting_balance: 0,
ending_balance: 0,
next_payment_attempt: null,
webhooks_delivered_at: null,
charge: 'ch_155TBdKaReE7xLUdRU0WcMzR',
discount: null,
application_fee: null,
subscription: 'sub_5Fz99gXrBtreNe',
metadata: {},
statement_description: null,
description: null,
receipt_number: null
}
},
object: 'event',
pending_webhooks: 1,
request: 'iar_5Fz9c4BZJyNNsM',
api_version: '2014-11-05'
}
customerSubscriptionDeletedSampleEvent = {
id: 'evt_155Tj4KaReE7xLUdpoMx0UaA',
created: 1417576970,
livemode: false,
type: 'customer.subscription.deleted',
data: {
object: {
id: 'sub_5FziOkege03vT7',
plan: [Object],
object: 'subscription',
start: 1417576967,
status: 'canceled',
customer: 'cus_5Fzi54gMvGG9Px',
cancel_at_period_end: true,
current_period_start: 1417576967,
current_period_end: 1420255367,
ended_at: 1417576970,
trial_start: null,
trial_end: null,
canceled_at: 1417576970,
quantity: 1,
application_fee_percent: null,
discount: null,
metadata: {}
}
},
object: 'event',
pending_webhooks: 1,
request: 'iar_5FziYQJ4oQdL6w',
api_version: '2014-11-05'
}
describe '/db/user, editing stripe property', ->
stripe = require('stripe')(config.stripe.secretKey)
userURL = getURL('/db/user')
webhookURL = getURL('/stripe/webhook')
it 'clears the db first', (done) ->
clearModels [User, Payment], (err) ->
throw err if err
done()
#- shared data between tests
joeData = null
firstSubscriptionID = null
it 'returns client error when a token fails to charge', (done) ->
stripe.tokens.create {
card: { number: '4000000000000002', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
stripeTokenID = token.id
loginJoe (joe) ->
joeData = joe.toObject()
joeData.stripe = {
token: stripeTokenID
planID: 'basic'
}
request.put {uri: userURL, json: joeData }, (err, res, body) ->
expect(res.statusCode).toBe(402)
done()
it 'creates a subscription when you put a token and plan', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
stripeTokenID = token.id
loginJoe (joe) ->
joeData = joe.toObject()
joeData.stripe = {
token: stripeTokenID
planID: 'basic'
}
request.put {uri: userURL, json: joeData }, (err, res, body) ->
joeData = body
expect(res.statusCode).toBe(200)
expect(joeData.purchased.gems).toBe(3500)
expect(joeData.stripe.customerID).toBeDefined()
expect(firstSubscriptionID = joeData.stripe.subscriptionID).toBeDefined()
expect(joeData.stripe.planID).toBe('basic')
expect(joeData.stripe.token).toBeUndefined()
done()
it 'records a payment through the webhook', (done) ->
# Don't even want to think about hooking in tests to webhooks, so... put in some data manually
stripe.invoices.list {customer: joeData.stripe.customerID}, (err, invoices) ->
expect(invoices.data.length).toBe(1)
event = _.cloneDeep(invoiceChargeSampleEvent)
event.data.object = invoices.data[0]
request.post {uri: webhookURL, json: event}, (err, res, body) ->
expect(res.statusCode).toBe(201)
Payment.find {}, (err, payments) ->
expect(payments.length).toBe(1)
User.findById joeData._id, (err, user) ->
expect(user.get('purchased').gems).toBe(3500)
done()
it 'schedules the stripe subscription to be cancelled when stripe.planID is removed from the user', (done) ->
delete joeData.stripe.planID
request.put {uri: userURL, json: joeData }, (err, res, body) ->
joeData = body
expect(res.statusCode).toBe(200)
expect(joeData.stripe.subscriptionID).toBeDefined()
expect(joeData.stripe.planID).toBeUndefined()
expect(joeData.stripe.customerID).toBeDefined()
stripe.customers.retrieve joeData.stripe.customerID, (err, customer) ->
expect(customer.subscriptions.data.length).toBe(1)
expect(customer.subscriptions.data[0].cancel_at_period_end).toBe(true)
done()
it 'allows you to sign up again using the same customer ID as before, no token necessary', (done) ->
joeData.stripe.planID = 'basic'
request.put {uri: userURL, json: joeData }, (err, res, body) ->
joeData = body
expect(res.statusCode).toBe(200)
expect(joeData.stripe.customerID).toBeDefined()
expect(joeData.stripe.subscriptionID).toBeDefined()
expect(joeData.stripe.subscriptionID).not.toBe(firstSubscriptionID)
expect(joeData.stripe.planID).toBe('basic')
done()
it 'will not have immediately created new payments when signing back up from a cancelled subscription', (done) ->
stripe.invoices.list {customer: joeData.stripe.customerID}, (err, invoices) ->
expect(invoices.data.length).toBe(2)
expect(invoices.data[0].total).toBe(0)
event = _.cloneDeep(invoiceChargeSampleEvent)
event.data.object = invoices.data[0]
request.post {uri: webhookURL, json: event}, (err, res, body) ->
expect(res.statusCode).toBe(200)
Payment.find {}, (err, payments) ->
expect(payments.length).toBe(1)
User.findById joeData._id, (err, user) ->
expect(user.get('purchased').gems).toBe(3500)
done()
it 'deletes the subscription from the user object when an event about it comes through the webhook', (done) ->
stripe.customers.retrieveSubscription joeData.stripe.customerID, joeData.stripe.subscriptionID, (err, subscription) ->
event = _.cloneDeep(customerSubscriptionDeletedSampleEvent)
event.data.object = subscription
request.post {uri: webhookURL, json: event}, (err, res, body) ->
User.findById joeData._id, (err, user) ->
expect(user.get('purchased').gems).toBe(3500)
expect(user.get('stripe').subscriptionID).toBeUndefined()
expect(user.get('stripe').planID).toBeUndefined()
done()