mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2025-04-02 16:21:01 -04:00
Merge branch 'master' of https://github.com/codecombat/codecombat
This commit is contained in:
commit
103d1035a2
31 changed files with 869 additions and 184 deletions
app
scripts/mongodb/queries
server
commons
payments
routes
users
test/server/functional
BIN
app/assets/images/pages/play/modal/subscribe-background.png
Normal file
BIN
app/assets/images/pages/play/modal/subscribe-background.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 247 KiB |
|
@ -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">
|
||||
|
|
|
@ -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 wizard–not 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"
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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' }
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -22,7 +22,6 @@
|
|||
top: 242px
|
||||
width: 720px
|
||||
height: 140px
|
||||
index: 1
|
||||
|
||||
.product
|
||||
width: 228px
|
||||
|
|
|
@ -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
|
||||
|
|
145
app/styles/play/modal/subscribe-modal.sass
Normal file
145
app/styles/play/modal/subscribe-modal.sass
Normal 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
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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 wizard–not
|
||||
| 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
|
||||
|
|
|
@ -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
|
||||
|
|
43
app/templates/play/modal/subscribe-modal.jade
Normal file
43
app/templates/play/modal/subscribe-modal.jade
Normal 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") ×
|
||||
|
||||
if state === 'unknown_error'
|
||||
#error-alert.alert.alert-danger.alert-dismissible
|
||||
button.close(type="button" data-dismiss="alert")
|
||||
span(aria-hidden="true") ×
|
||||
p(data-i18n="loading_error.unknown")
|
||||
p= stateMessage
|
|
@ -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.
|
||||
|
|
|
@ -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) ->
|
||||
|
|
|
@ -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']
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
84
app/views/play/modal/SubscribeModal.coffee
Normal file
84
app/views/play/modal/SubscribeModal.coffee
Normal 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()
|
|
@ -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
|
||||
}
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -30,6 +30,7 @@ module.exports.routes =
|
|||
'routes/sprites'
|
||||
'routes/queue'
|
||||
'routes/stacklead'
|
||||
'routes/stripe'
|
||||
]
|
||||
|
||||
mongoose = require 'mongoose'
|
||||
|
|
141
server/payments/subscription_handler.coffee
Normal file
141
server/payments/subscription_handler.coffee
Normal 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()
|
64
server/routes/stripe.coffee
Normal file
64
server/routes/stripe.coffee
Normal 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, '')
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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) ->
|
||||
|
|
199
test/server/functional/subscription.spec.coffee
Normal file
199
test/server/functional/subscription.spec.coffee
Normal 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()
|
Loading…
Add table
Reference in a new issue