Merge branch 'master' into production

This commit is contained in:
Scott Erickson 2016-07-08 09:07:20 -07:00
commit 28c5865db2
75 changed files with 2517 additions and 1040 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -16,4 +16,11 @@ module.exports = {
options.url = '/contact'
$.ajax(options)
sendParentSignupInstructions: (parentEmail) ->
jqxhr = $.ajax('/contact/send-parent-signup-instructions', {
method: 'POST'
data: {parentEmail}
})
return new Promise(jqxhr.then)
}

View file

@ -57,6 +57,14 @@ module.exports.applyErrorsToForm = (el, errors, warning=false) ->
message = error.message if error.formatted
prop = error.property
if error.code is tv4.errorCodes.FORMAT_CUSTOM
originalMessage = /Format validation failed \(([^\(\)]+)\)/.exec(message)[1]
unless _.isEmpty(originalMessage)
message = originalMessage
if error.code is 409 and error.property is 'email'
message += ' <a class="login-link">Log in?</a>'
missingErrors.push error unless setErrorToProperty el, prop, message, warning
missingErrors

View file

@ -256,8 +256,13 @@
signup_switch: "Want to create an account?"
signup:
email_announcements: "Receive announcements by email"
create_student_header: "Create Student Account"
create_teacher_header: "Create Teacher Account"
create_individual_header: "Create Individual Account"
create_header: "Create Account"
email_announcements: "Receive announcements about new CodeCombat levels and features!" # {change}
creating: "Creating Account..."
create_account: "Create Account"
sign_up: "Sign Up"
log_in: "log in with password"
required: "You need to log in before you can go that way."
@ -265,7 +270,7 @@
school_name: "School Name and City"
optional: "optional"
school_name_placeholder: "Example High School, Springfield, IL"
or_sign_up_with: "or sign up with"
connect_with: "Connect with:"
connected_gplus_header: "You've successfully connected with Google+!"
connected_gplus_p: "Finish signing up so you can log in with your Google+ account."
gplus_exists: "You already have an account associated with Google+!"
@ -274,6 +279,45 @@
facebook_exists: "You already have an account associated with Facebook!"
hey_students: "Students, enter the class code from your teacher."
birthday: "Birthday"
parent_email_blurb: "We know you can't wait to learn programming &mdash; we're excited too! Your parents will receive an email with further instructions on how to create an account for you. Email {{email_link}} if you have any questions."
classroom_not_found: "No classes exist with this Class Code. Check your spelling or ask your teacher for help."
checking: "Checking..."
account_exists: "This email is already in use:" # {change}
sign_in: "Sign in"
email_good: "Email looks good!"
name_taken: "Username already taken! Try {{suggestedName}}?"
name_available: "Username available!"
choose_type: "Choose your account type:"
teacher_type_1: "Teach programming using CodeCombat!"
teacher_type_2: "Set up your class"
teacher_type_3: "Access Course Guides"
teacher_type_4: "View student progress"
signup_as_teacher: "Sign up as a Teacher"
student_type_1: "Learn to program while playing an engaging game!"
student_type_2: "Play with your class"
student_type_3: "Compete in arenas"
student_type_4: "Choose your hero!"
student_type_5: "Have your Class Code ready!"
signup_as_student: "Sign up as a Student"
individuals_or_parents: "Individuals & Parents"
individual_type: "For players learning to code outside of a class. Parents should sign up for an account here."
signup_as_individual: "Sign up as an Individual"
enter_class_code: "Enter your Class Code"
enter_birthdate: "Enter your birthdate:"
ask_teacher_1: "Ask your teacher for your Class Code."
ask_teacher_2: "Not part of a class? Create an "
ask_teacher_3: "Individual Account"
ask_teacher_4: " instead."
about_to_join: "You're about to join:"
enter_parent_email: "Enter your parents email address:"
parent_email_error: "Something went wrong when trying to send the email. Check the email address and try again."
parent_email_sent: "Weve sent an email with further instructions on how to create an account. Ask your parent to check their inbox."
account_created: "Account Created!"
confirm_student_blurb: "Write down your information so that you don't forget it. Your teacher can also help you reset your password at any time."
confirm_individual_blurb: "Write down your login information in case you need it later. Verify your email so you can recover your account if you ever forget your password - check your inbox!"
write_this_down: "Write this down:"
start_playing: "Start Playing!"
sso_connected: "Successfully connected with:"
recover:
recover_account_title: "Recover Account"
@ -295,6 +339,7 @@
saving: "Saving..."
sending: "Sending..."
send: "Send"
sent: "Sent"
type: "Type"
cancel: "Cancel"
save: "Save"
@ -365,6 +410,7 @@
wizard: "Wizard"
first_name: "First Name"
last_name: "Last Name"
last_initial: "Last Initial"
username: "Username"
units:

View file

@ -5,7 +5,7 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
no_mobile: "¡CodeCombat no fue diseñado para dispositivos móviles y puede que no funcione!" # Warning that shows up on mobile devices
play: "Jugar" # The big play button that opens up the campaign view.
play_campaign_version: "Juega a la versión camapaña." # Shows up under big play button if you only play /courses
old_browser: "Ay, su navegador es demasiado viejo para ejecutar CodeCombat. ¡Lo sentimos!" # Warning that shows up on really old Firefox/Chrome/Safari
old_browser: "Ay, tu navegador es demasiado viejo para ejecutar CodeCombat. ¡Lo sentimos!" # Warning that shows up on really old Firefox/Chrome/Safari
old_browser_suffix: "Lo puede intentar de todos modos, pero probablemente no va a funcionar."
ipad_browser: "Malas noticias: CodeCombat no corre en el navegador de iPad. Buenas noticias: nuestra aplicación para iPad está esperando la aprobación de Apple."
campaign: "Campaña"
@ -27,16 +27,16 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
# classroom_in_a_box: "A classroom in-a-box for teaching computer science."
codecombat_is: "CodeCombat es una plataforma <strong>para que los estudiantes</strong> aprendan ciencia de la computación mientras juegan a un juego real."
# our_courses: "Our courses have been specifically playtested to <strong>excel in the classroom</strong>, even by teachers with little to no prior programming experience."
top_screenshots_hint: "Los estudiantes escriben código y ven sus cambios en tiempo real"
top_screenshots_hint: "Los estudiantes escriben código y ven sus cambios en tiempo real."
designed_with: "Diseñado pensando en los profesores"
real_code: "Real, escribe código"
from_the_first_level: "desde el primer nivel"
from_the_first_level: "desde el primer nivel."
getting_students: "Involucrar a los alumnos en la programación por sentencias tan rápido como sea posible es fundamental para aprender la sintaxis de la programación con una estructura apropiada."
educator_resources: "Recursos para educadores"
course_guides: "y guías de cursos"
teaching_computer_science: "Enseñar ciencias computación no requiere de un costoso título, porque nosotros proveemos las herramientas para apoyar educadores con cualquier nivel de conocimientos."
course_guides: "y guías de cursos."
teaching_computer_science: "Enseñar ciencias de la computación no requiere de un costoso título, porque nosotros proveemos las herramientas para apoyar educadores con cualquier nivel de conocimientos."
accessible_to: "Accesible para"
everyone: "todo el mundo"
everyone: "todo el mundo."
democratizing: "La democratización del proceso de aprendizaje es el nucleo de nuestra filosofía. Todo mundo debe ser capaz de aprender a programar."
forgot_learning: "En realidad creo que que ellos olvidaron que en realidad están aprendiendo algo."
wanted_to_do: " Programar es algo que siempre he querido hacer, nunca pensé que sería capáz de aprenderlo en la escuela."
@ -49,10 +49,10 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
motivating: "motivante"
not_tedious: "no tedioso."
gaming_is_good: "Estudios sugieren que el jugar es bueno para el cerebro de los niños. (¡Es verdad!)"
# game_based: "When game-based learning systems are"
# compared: "compared"
# conventional: "against conventional assessment methods, the difference is clear: games are better at helping students retain knowledge, concentrate and"
# perform_at_higher_level: "perform at a higher level of achievement"
game_based: "Cuando los sistemas de aprendizaje basados en juegos son"
compared: "comparados"
conventional: "contra los métodos convencionales de evaluación, la diferencia es clara: los juegos son mejores ayudando a los alumnos a retener conocimiento, concentrarse y"
perform_at_higher_level: "desempeñarse a un nivel mas alto de ejecución."
# feedback: "Games also provide real-time feedback that allows students to adjust their solution path and understand concepts more holistically, instead of being limited to just “correct” or “incorrect” answers."
# real_game: "A real game, played with real coding."
# great_game: "A great game is more than just badges and achievements - its about a players journey, well-designed puzzles, and the ability to tackle challenges with agency and confidence."
@ -76,11 +76,11 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
# boast: "Boasts riddles that are complex enough to fascinate gamers and coders alike."
# winning: "A winning combination of RPG gameplay and programming homework that pulls off making kid-friendly education legitimately enjoyable."
# run_class: "Everything you need to run a computer science class in your school today, no CS background required."
# teachers: "Teachers!"
# teachers_and_educators: "Teachers & Educators"
teachers: "Profesores"
teachers_and_educators: "Profesores y Educadores"
# class_in_box: "Learn how our classroom-in-a-box platform fits into your curriculum."
# get_started: "Get Started"
# students: "Students:"
students: "Alumnos:"
# join_class: "Join Class"
# role: "Your role:"
# student_count: "Number of students:"
@ -102,10 +102,10 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
blog: "Blog"
forum: "Foro"
account: "Cuenta"
# my_account: "My Account"
my_account: "Mi Cuenta"
profile: "Perfil"
stats: "Estadisticas"
code: "Codigo"
code: "Código"
home: "Inicio"
contribute: "Colaborar"
legal: "Legalidad"
@ -148,7 +148,7 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
play:
play_as: "Jugar como" # Ladder page
# compete: "Compete!" # Course details page
compete: "¡Compite!" # Course details page
spectate: "Observar" # Ladder page
players: "jugadores" # Hover over a level on /play
hours_played: "horas jugadas" # Hover over a level on /play
@ -323,7 +323,7 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
accept: "Aceptar"
reject: "Rechazar"
withdraw: "Retirar"
submitter: "Submitter"
#submitter: "Submitter"
submitted: "Enviado"
commit_msg: "Mensaje de Asignación o Commit"
version_history: "Historial de versión"
@ -597,7 +597,7 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
thank_you_months_suffix: "meses."
thank_you: "Gracias por apoyar a CodeCombat."
sorry_to_see_you_go: "¡Lamentamos verte marchar! Por favor, haznos saber que pudimos haacer mejor."
# unsubscribe_feedback_placeholder: "O, what have we done?"
unsubscribe_feedback_placeholder: "Oh, ¿Qué hemos hecho?"
parent_button: "Pregunta a tus padres"
parent_email_description: "Le escribiremos para que puedan comprarte una suscripción para CodeCombat."
parent_email_input_invalid: "Correo electrónico inválido."
@ -611,7 +611,7 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
parents_blurb1: "Su hijo ha jugado __nLevels__ niveles y ha aprendido conceptos básicos de programación. Ayudadle a mejorar su eficiencia adquiriendo una suscripción para poder seguir programando."
parents_blurb1a: "La programación informática es una habilidad fundamental que su hijo usará indudablemente cuando sea un adulto. Aproximadamente en 2020, se necesitarán conceptos de programación en el 77% de los empleos, y los ingenieros programadores están muy demandados en todo el mundo. ¿Sabían que la Ingeniería Informática es la titulación que más dinero paga?"
parents_blurb2: "Por tan sólo ${{price}} USD/mes, su hijo podrá afrontar nuevos retos cada semana, y recibirá soporte por correo electrónico de programadores profesionales."
parents_blurb3: "Sin riesgo: 100% garantía de devoluación de dinero, desuscripción con un simple click."
parents_blurb3: "Sin riesgo: 100% garantía de devolución de dinero, cancela tu suscripción con un simple click."
payment_methods: "Métodos de pago"
payment_methods_title: "Métodos de pago permitidos"
payment_methods_blurb1: "Actualmente aceptamos pagos a través de tarjetas de crédito / débito y Alipay." # {change}
@ -975,7 +975,7 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
social_facebook: "Dale a Me Gusta a CodeCombat en Facebook"
social_twitter: "Sigue a CodeCombat en Twitter"
social_gplus: "Unete a CodeCombat en Google+"
# social_slack: "Chat with us in the public CodeCombat Slack channel"
social_slack: "Chatea con nosotros en el canal público de CodeCombat"
contribute_to_the_project: "Contribuye al proyecto"
clans:

View file

@ -233,9 +233,9 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese
None: "無值"
share_progress_modal:
blurb: "您正在建立優秀的進度告訴您的家長您從CodeCombat學到了什麼" # {change}
blurb: "您正在創造偉大的旅程告訴您的家長您從CodeCombat學到了什麼" # {change}
email_invalid: "郵件地址無效"
form_blurb: "在底下輸入他們的郵件並且我們將秀給他們!"
form_blurb: "在底下輸入家長的郵件,讓我們展示給他們!"
form_label: "郵件地址"
placeholder: "郵件地址"
title: "出色的作品,學徒"
@ -760,28 +760,28 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese
retrostyle_blurb: "復古風格的遊戲"
jose_title: "音樂"
jose_blurb: "放輕鬆"
# community_title: "...and our open-source community"
# community_subtitle: "Over 450 contributors have helped build CodeCombat, with more joining every week!"
# community_description_1: "CodeCombat is a community project, with hundreds of players volunteering to create levels, contribute to our code to add features, fix bugs, playtest, and even translate the game into 50 languages so far. Employees, contributors and the site gain by sharing ideas and pooling effort, as does the open source community in general. The site is built on numerous open source projects, and we are open sourced to give back to the community and provide code-curious players a familiar project to explore and experiment with. Anyone can join the CodeCombat community! Check out our"
# community_description_link: "contribute page"
# community_description_2: "for more info."
# number_contributors: "Over 450 contributors have lent their support and time to this project."
# story_title: "Our story so far"
# story_subtitle: "Since 2013, CodeCombat has grown from a mere set of sketches to a living, thriving game."
# story_statistic_1a: "5,000,000+"
# story_statistic_1b: "total players"
# story_statistic_1c: "have started their programming journey through CodeCombat"
# story_statistic_2a: "Weve been translated into over 50 languages — our players hail from"
# story_statistic_2b: "200+ countries"
# story_statistic_3a: "Together, they have written"
# story_statistic_3b: "1 billion lines of code and counting"
# story_statistic_3c: "across many different programming languages"
# story_long_way_1: "Though we've come a long way..."
# story_sketch_caption: "Nick's very first sketch depicting a programming game in action."
# story_long_way_2: "we still have much to do before we complete our quest, so..."
# jobs_title: "Come work with us and help write CodeCombat history!"
# jobs_subtitle: "Don't see a good fit but interested in keeping in touch? See our \"Create Your Own\" listing."
# jobs_benefits: "Employee Benefits"
community_title: "...以及我們的開放原始碼社群" #"...and our open-source community"
community_subtitle: "超過 450 位貢獻者協助建立 CodeCombat , 而且每週持續增加中!" #"Over 450 contributors have helped build CodeCombat, with more joining every week!"
community_description_1: "CodeCombat 是一個社群專案,由有數以百計的玩家志願來建立遊戲關卡,建構我們的程式碼來添加功能、修正 Bugs、執行測試、甚至翻譯此遊戲至超過 50 種語言。如同多數的開源社群一般,所有的員工、貢獻者們及 CodeCombat 都獲益於持續的互相分享靈感和彙整努力。CodeCombat 是建立於無數的開源專案之上,因此我們開源回饋給社群朋友,提供一個友善專案給對於原始碼有興趣的玩家來探索和實驗。任何人都可以加入 CodeCombat 社群!請查看我們的" #"CodeCombat is a community project, with hundreds of players volunteering to create levels, contribute to our code to add features, fix bugs, playtest, and even translate the game into 50 languages so far. Employees, contributors and the site gain by sharing ideas and pooling effort, as does the open source community in general. The site is built on numerous open source projects, and we are open sourced to give back to the community and provide code-curious players a familiar project to explore and experiment with. Anyone can join the CodeCombat community! Check out our"
community_description_link: "貢獻者頁" #"contribute page"
community_description_2: "來獲得更多資訊。" #"for more info."
number_contributors: "超過 450 位貢獻者奉獻他們的時間來協助本專案。" #"Over 450 contributors have lent their support and time to this project."
story_title: "我們的故事..." #"Our story so far"
story_subtitle: "從 2013 年起CodeCombat 從簡單的草圖成長為一個有生命且生氣蓬勃的遊戲。" #"Since 2013, CodeCombat has grown from a mere set of sketches to a living, thriving game."
story_statistic_1a: "總計超過 5,000,000+ " #"5,000,000+"
story_statistic_1b: "位玩家" #"total players"
story_statistic_1c: "藉由 CodeCombat 開啟他們的程式之旅" #"have started their programming journey through CodeCombat"
story_statistic_2a: "我們已翻譯至超過 50 種語言,我們的玩家來自" #"Weve been translated into over 50 languages — our players hail from"
story_statistic_2b: "超過 200+ 個國家" #"200+ countries"
story_statistic_3a: "他們一起撰寫完成" #"Together, they have written"
story_statistic_3b: "一百萬行程式及計算" #"1 billion lines of code and counting"
story_statistic_3c: "跨越數種不同的程式語言" #"across many different programming languages"
story_long_way_1: "雖然我們已經走過很長的一段路..." #"Though we've come a long way..."
story_sketch_caption: "Nick 著手完成了一個初版的藍圖,描述一個撰寫程式的遊戲。" #"Nick's very first sketch depicting a programming game in action."
story_long_way_2: "然而在完成任務之前,我們仍然還有許多事情要做。所以..." #"we still have much to do before we complete our quest, so..."
jobs_title: "與我們一同工作,來協助撰寫 CodeCombat 的歷史吧!" #"Come work with us and help write CodeCombat history!"
jobs_subtitle: "找不到符合的位置,但有興趣與我們保持連繫?查看『建立我的』清單吧。" #"Don't see a good fit but interested in keeping in touch? See our \"Create Your Own\" listing."
jobs_benefits: "員工福利" #"Employee Benefits"
# jobs_benefit_4: "Unlimited vacation"
# jobs_benefit_5: "Professional development and continuing education support free books and games!"
# jobs_benefit_6: "Medical (gold), dental, vision"
@ -789,29 +789,29 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese
# jobs_benefit_9: "10-year option exercise window"
# jobs_benefit_10: "Maternity leave: 10 weeks paid, next 6 @ 55% salary"
# jobs_benefit_11: "Paternity leave: 10 weeks paid"
# learn_more: "Learn More"
# jobs_custom_title: "Create Your Own"
learn_more: "了解更多" #"Learn More"
jobs_custom_title: "建立我的" #"Create Your Own"
# jobs_custom_description: "Are you passionate about CodeCombat but don't see a job listed that matches your qualifications? Write us and show how you think you can contribute to our team. We'd love to hear from you!"
# jobs_custom_contact_1: "Send us a note at"
# jobs_custom_contact_2: "introducing yourself and we might get in touch in the future!"
# contact_title: "Press & Contact"
# contact_subtitle: "Need more information? Get in touch with us at"
# screenshots_title: "Game Screenshots"
# screenshots_hint: "(click to view full size)"
# downloads_title: "Download Assets & Information"
# about_codecombat: "About CodeCombat"
contact_title: "點擊 & 連繫" #"Press & Contact"
contact_subtitle: "需要更多資訊?透過以下與我們連繫" #"Need more information? Get in touch with us at"
screenshots_title: "遊戲螢幕截圖" #"Game Screenshots"
screenshots_hint: "(點擊查看完整尺寸)" #"(click to view full size)"
downloads_title: "下載資源 & 資訊" #"Download Assets & Information"
about_codecombat: "關於 CodeCombat" #"About CodeCombat"
# logo: "Logo"
# screenshots: "Screenshots"
# character_art: "Character Art"
# download_all: "Download All"
# previous: "Previous"
# next: "Next"
# location_title: "We're located in downtown SF:"
screenshots: "螢幕截圖" #"Screenshots"
character_art: "角色美術作品" #"Character Art"
download_all: "下載全部" #"Download All"
previous: "上一個" #"Previous"
next: "下一個" #"Next"
location_title: "我們位於城鎮 SF" #"We're located in downtown SF:"
teachers:
who_for_title: "誰是CodeCombat的使用對象呢"
who_for_1: "我們建議讓9歲及以上的學生使用CodeCombat。無需任何編程經驗。" # {change}
who_for_2: "我們設計CodeCombat來吸引男生女生。" # {change}
who_for_title: "誰是 CodeCombat 的使用對象呢?"
who_for_1: "我們建議讓 9 歲及以上的學生使用 CodeCombat ,無需任何程式撰寫經驗。我們設計 CodeCombat 來吸引不分男女老幼的孩子們。" #"We recommend CodeCombat for students aged 9 and up. No prior programming experience is needed. We've designed CodeCombat to appeal to both boys and girls."
who_for_2: "我們的課程系統允許教師們藉由專屬的介面來設定課堂,追蹤學習進度及指派額外的內容給學生們" #"Our Courses system allows teachers to set up classrooms, track progress and assign additional content to students through a dedicated interface."
more_info_title: "我可以在哪裡找到更多訊息?"
more_info_1: "我們的"
more_info_2: "教師論壇"

View file

@ -47,12 +47,24 @@ module.exports = class User extends CocoModel
super arguments...
@getUnconflictedName: (name, done) ->
# deprecate in favor of @checkNameConflicts, which uses Promises and returns the whole response
$.ajax "/auth/name/#{encodeURIComponent(name)}",
cache: false
success: (data) -> done data.name
statusCode: 409: (data) ->
response = JSON.parse data.responseText
done response.name
success: (data) -> done(data.suggestedName)
@checkNameConflicts: (name) ->
new Promise (resolve, reject) ->
$.ajax "/auth/name/#{encodeURIComponent(name)}",
cache: false
success: resolve
error: (jqxhr) -> reject(jqxhr.responseJSON)
@checkEmailExists: (email) ->
new Promise (resolve, reject) ->
$.ajax "/auth/email/#{encodeURIComponent(email)}",
cache: false
success: resolve
error: (jqxhr) -> reject(jqxhr.responseJSON)
getEnabledEmails: ->
(emailName for emailName, emailDoc of @get('emails', true) when emailDoc.enabled)
@ -259,6 +271,38 @@ module.exports = class User extends CocoModel
window.location.reload()
@fetch(options)
signupWithPassword: (email, password, options={}) ->
options.url = _.result(@, 'url') + '/signup-with-password'
options.type = 'POST'
options.data ?= {}
_.extend(options.data, {email, password})
jqxhr = @fetch(options)
jqxhr.then ->
window.tracker?.trackEvent 'Finished Signup', category: "Signup", label: 'CodeCombat'
return jqxhr
signupWithFacebook: (email, facebookID, options={}) ->
options.url = _.result(@, 'url') + '/signup-with-facebook'
options.type = 'POST'
options.data ?= {}
_.extend(options.data, {email, facebookID, facebookAccessToken: application.facebookHandler.token()})
jqxhr = @fetch(options)
jqxhr.then ->
window.tracker?.trackEvent 'Facebook Login', category: "Signup", label: 'Facebook'
window.tracker?.trackEvent 'Finished Signup', category: "Signup", label: 'Facebook'
return jqxhr
signupWithGPlus: (email, gplusID, options={}) ->
options.url = _.result(@, 'url') + '/signup-with-gplus'
options.type = 'POST'
options.data ?= {}
_.extend(options.data, {email, gplusID, gplusAccessToken: application.gplusHandler.token()})
jqxhr = @fetch(options)
jqxhr.then ->
window.tracker?.trackEvent 'Google Login', category: "Signup", label: 'GPlus'
window.tracker?.trackEvent 'Finished Signup', category: "Signup", label: 'GPlus'
return jqxhr
fetchGPlusUser: (gplusID, options={}) ->
options.data ?= {}
options.data.gplusID = gplusID

View file

@ -26,7 +26,7 @@
display: flex
flex-direction: column
align-items: center
margin: 0 50px 50px
margin: 0 48px 50px
.hero-avatar
margin: 6px

View file

@ -1,249 +0,0 @@
@import "app/styles/mixins"
@import "app/styles/bootstrap/variables"
#create-account-modal
//- Clear modal defaults
.modal-dialog
padding: 0
width: 666px
height: 694px
//- Background
.auth-modal-background
position: absolute
top: -90px
left: -40px
//- Header
h1
position: absolute
left: 183px
top: 0px
margin: 0
width: 255px
text-align: center
color: rgb(254,188,68)
font-size: 32px
text-shadow: black 2px 2px 0, black -2px -2px 0, black 2px -2px 0, black -2px 2px 0, black 2px 0px 0, black 0px -2px 0, black -2px 0px 0, black 0px 2px 0
&.long-title
top: -14px
//- Close modal button
#close-modal
position: absolute
left: 442px
top: -15px
width: 60px
height: 60px
color: white
text-align: center
font-size: 30px
padding-top: 15px
cursor: pointer
@include rotate(-3deg)
&:hover
color: yellow
//- Modal body content
#gplus-account-exists-row, #facebook-account-exists-row
margin-bottom: 20px
#facebook-signup-btn
margin-bottom: 5px
.auth-form-content
position: absolute
top: 100px
left: 40px
width: 588px
.help-block
margin: 0
.alert
margin-top: -25px
margin-bottom: 0
padding: 10px 15px
.form-group
color: rgb(51,51,51)
padding: 0
margin: 0
.input-border
border: 2px solid rgb(233, 221, 194)
border-radius: 4px
margin-bottom: 7px
input, select
background-color: rgb(239, 232, 216)
border: 2px solid rgb(26, 21, 18)
border-radius: 4px
select
margin-right: 2px
label
font-size: 20px
text-transform: uppercase
font-family: $headings-font-family
margin-bottom: 0
.optional-note
font-size: 14px
.well
font-size: 18px
position: absolute
right: 0
bottom: 0
width: 278px
margin-bottom: 0
//- Check boxes
.form-group.checkbox
margin: 10px 0
label
position: relative
line-height: 34px
span:not(.custom-checkbox)
margin-left: 40px
input
display: none
& + .custom-checkbox
.glyphicon
display: none
&:checked + .custom-checkbox .glyphicon
display: inline
color: rgb(248,169,67)
text-align: center
text-shadow: 0 0 3px black, 0 0 3px black, 0 0 3px black
font-size: 20px
position: relative
top: -2px
.input-border
border-radius: 4px
height: 34px
width: 34px
position: absolute
.custom-checkbox
border-radius: 4px
position: absolute
height: 30px
width: 30px
border: 2px solid rgb(26,21,18)
background: rgb(228,217,196)
text-align: center
//- Primary auth button
#signup-button
position: absolute
top: 405px
height: 70px
font-size: 32px
line-height: 42px
//- Footer area
.auth-network-logins
.btn.btn-lg.network-login
width: 251px
height: 60px
text-align: center
position: relative
.network-logo
height: 30px
position: absolute
left: -10px
top: 2px
.sign-in-blurb
line-height: 34px
margin-left: 12px
.fb-login-button
$scaleX: 251 / 64
$scaleY: 60 / 23
transform: scale($scaleX, $scaleY)
position: absolute
top: 4px
left: 74px
@include opacity(0.01)
.gplus-login-wrapper
position: absolute
left: 65px
top: -6px
$scaleX: 251 / 84
$scaleY: 60 / 24
transform: scale($scaleX, $scaleY)
@include opacity(0.01)
#github-login-button
position: relative
top: -1px
border-radius: 5px
img
width: 16px
margin: 0 5px 0 -5px
#gplus-login-button
position: relative
top: 8px
//- Extra bottom pane area
.extra-pane
background-image: url(/images/pages/modal/auth/extra-pane.png)
width: 633px
height: 139px
padding: 23px 23px 23px 168px
position: absolute
top: 580px
.switch-explanation
margin: 25px 10px 0 0
width: 200px
color: rgb(254,188,68)
font-size: 20px
font-family: $headings-font-family
font-weight: bold
text-transform: uppercase
text-shadow: black 1px 1px 0, black -1px -1px 0, black 1px -1px 0, black -1px 1px 0, black 1px 0px 0, black 0px -1px 0, black -1px 0px 0, black 0px 1px 0
float: left
.btn
float: right
margin-top: 20px
width: 230px
height: 70px
line-height: 40px
.ie10 #create-account-modal .auth-network-logins .btn.btn-lg.network-login .network-logo, .lt-ie10 #create-account-modal .auth-network-logins .btn.btn-lg.network-login .network-logo
left: 15px
top: 15px

View file

@ -0,0 +1,30 @@
#basic-info-view
.network-login
width: 175px
height: 40px
border: solid 0.5px darkgray
span
visibility: hidden
// Forms
.basic-info
display: flex
flex-direction: column
.form-group
text-align: left
.btn-illustrated img
// Undo previous opacity-toggling hover behavior
opacity: 1
label
margin-bottom: 0
.help-block
margin: 0
.form-container
width: 800px

View file

@ -0,0 +1,68 @@
@import "app/styles/style-flat-variables"
#choose-account-type-view
.path-cards
margin-top: 15px
display: flex
.path-card ~ .path-card
margin-left: 6.5px
.path-card
display: flex
flex-direction: column
justify-content: space-between
width: 235px
min-height: 340px
border-style: solid
border-width: thin
border-radius: 5px
&.navy
border-color: $navy
.card-title
background-color: $navy
&.forest
border-color: $forest
.card-title
background-color: $forest
.card-title
display: flex
flex-direction: column
align-items: center
justify-content: center
height: 50px
color: white
font-weight: bold
text-align: center
.card-content
flex-grow: 1
display: flex
flex-direction: column
margin: 50px 20px 0
ul
align-self: center
text-align: left
padding-left: 20px
li
span
position: relative
left: -5px
.card-footer
margin: 20px
.individual-section
margin-top: 50px
max-width: 425px
.individual-title
font-weight: bold
.text-h6
color: white

View file

@ -0,0 +1,15 @@
@import "app/styles/style-flat-variables"
#confirmation-view
text-align: left
.signup-info-box-wrapper
width: 100%
.signup-info-box
padding: 10px 20px
border: 2px dashed $burgandy
.modal-body-content
width: 80%
margin-left: 10%

View file

@ -0,0 +1,17 @@
#coppa-deny-view
.parent-email-blurb
width: 500px
.parent-email-input-group
display: flex
flex-direction: row
align-items: center
text-align: center
.glyphicon
width: 0
line-height: 40px
font-size: 30px
.error
color: red

View file

@ -0,0 +1,168 @@
//
Layout for CreateAccountModal is done largely with flexboxes.
Rules in this file should be ones that apply to all screens/subviews, but this
separation may not be perfect.
Currently it uses .modal-dialog, .modal-content, etc, for some parts of the modal.
Unfortunately those preexesting classes don't line up perfectly with the needs of this modal,
so many of the other styles for those classes don't apply or are overridden.
@import "app/styles/style-flat-variables"
#create-account-modal
.modal-dialog
width: 850px
.modal-content
display: flex
flex-direction: column
height: 850px
width: 850px
text-align: center
padding: 0
border: none
// General modal stuff
.close
color: white
opacity: 0.5
right: 7px
&:hover
opacity: 0.9
.modal-header, .modal-footer
&.teacher
background-color: $burgandy
&.student
background-color: $forest
.modal-header
display: flex
flex-direction: column
align-items: center
justify-content: flex-end
height: 100px
padding: 0
background-color: $navy
h3
color: white
.modal-footer
padding: 0
margin: 0
height: 50px
display: flex
align-items: center
justify-content: center
background-color: $navy
span
color: white
a span
text-decoration: underline
#choose-account-type-view, #segment-check-view, #basic-info-view, #coppa-deny-view, #single-sign-on-already-exists-view, #single-sign-on-confirm-view, #confirmation-view
display: flex
flex-direction: column
flex-grow: 1
.modal-body
display: flex
flex-direction: column
flex-grow: 1
.modal-body-content
flex-grow: 2
display: flex
flex-direction: column
align-items: center
justify-content: center
// Back/forward buttons
.history-nav-buttons
width: 100%
display: flex
flex-direction: row-reverse
flex-grow: 1
align-items: flex-end
justify-content: space-between
.btn
// Undo .style-flat's .btn ~ .btn margin
margin: 0
&.just-one
flex-direction: row
// Forms
.form-container
width: 800px
.form-group
text-align: left
.full-name
display: flex
flex-direction: row
.form-group
display: flex
flex-direction: column
align-content: flex-start
&.text-center
text-align: center
input
height: 40px
&.row
display: block
&.last-initial
margin-left: 30px
width: auto
input
width: 70px
&.subscribe
width: 100%
// Fancy text inside horizontal rules
.hr-text
position: relative
hr
width: 430px
padding: 0
border: none
border-top: thin solid #444
color: #444
span
position: absolute
left: 50%
top: 0.45em
transform: translateX(-50%)
padding: 0 0.75em
font-weight: bold
font-size: 10pt
background: white
// Glyphicon colors
.glyphicon-ok-circle
color: green
.glyphicon-remove-circle
color: red

View file

@ -0,0 +1,18 @@
#segment-check-view
.class-code-input-group
display: flex
flex-direction: row
align-items: center
.class-code-input
text-align: center
.glyphicon
width: 0
line-height: 40px
font-size: 30px
.classroom-name
font-size: 26pt
.teacher-name
font-size: 14pt

View file

@ -0,0 +1,5 @@
#single-sign-on-already-exists-view
.modal-body
display: flex
flex-direction: column
align-items: center

View file

@ -0,0 +1,5 @@
#single-sign-on-confirm-view
.modal-body
display: flex
flex-direction: column
align-items: center

View file

@ -68,7 +68,7 @@
margin-top: 10px
&.alt-image
background-image: url("/images/pages/home/student_jumbotron.png")
background-image: url("/images/pages/home/student_jumbotron.jpg")
background-position: 60% 35%
.well

View file

@ -393,6 +393,12 @@ body[lang='ru'], body[lang='uk'], body[lang='bg'], body[lang^='mk'], body[lang='
.text-navy
color: $navy
.text-burgandy
color: $burgandy
.text-forest
color: $forest
.bg-navy
background-color: $navy
color: white
@ -490,4 +496,4 @@ body[lang='ru'], body[lang='uk'], body[lang='bg'], body[lang^='mk'], body[lang='
.button.close
position: absolute
top: 10px
left: 10px
right: 10px

View file

@ -44,6 +44,9 @@ block content
ul
li
a(href="/admin/classroom-levels") Classroom Levels
li
button.classroom-progress-csv.btn.btn-sm.btn-success Classroom Progress CSV
input.classroom-progress-class-code(type=text value="<class code>")
li
a(href="/admin/analytics") Dashboard
li

View file

@ -1,11 +0,0 @@
extends /templates/core/modal-base-flat
block modal-header-content
block modal-body-content
p
h2(data-i18n="coppa_deny.text1")
div(data-i18n="coppa_deny.text2")
block modal-footer-content
a.btn.btn-primary(href='#', data-dismiss="modal", aria-hidden="true", data-i18n="coppa_deny.close").btn

View file

@ -1,118 +0,0 @@
.modal-dialog
.modal-content
img(src="/images/pages/modal/auth/signup-background.png", draggable="false").auth-modal-background
h1(data-i18n="login.sign_up")
div#close-modal
span.glyphicon.glyphicon-remove
form.auth-form-content
if view.options.showRequiredError
#required-error-alert.alert.alert-success
span(data-i18n="signup.required")
if view.options.showSignupRationale
#signup-rationale-alert.alert.alert-info
span(data-i18n="play_level.victory_sign_up_poke")
#email-password-row.row
.col-md-6
.form-group
label.control-label(for="email")
span(data-i18n="general.email")
| :
.input-border
input#email.input-large.form-control(name="email", type="email", value=view.previousFormInputs.email, tabindex=1)
.form-group
label.control-label(for="password")
span(data-i18n="general.password")
| :
.input-border
input#password.input-large.form-control(name="password", type="password", value=view.previousFormInputs.password, tabindex=2)
.col-md-6
.auth-network-logins.text-center
strong(data-i18n="signup.or_sign_up_with")
button#facebook-signup-btn.btn.btn-primary.btn-lg.btn-illustrated.network-login(type="button", disabled=true)
img.network-logo(src="/images/pages/community/logo_facebook.png", draggable="false")
span.sign-in-blurb(data-i18n="login.sign_in_with_facebook")
button#gplus-signup-btn.btn.btn-danger.btn-lg.btn-illustrated.network-login(type="button", disabled=true)
img.network-logo(src="/images/pages/community/logo_g+.png", draggable="false")
span.sign-in-blurb(data-i18n="login.sign_in_with_gplus")
.gplus-login-wrapper
.gplus-login-button
#gplus-logged-in-row.row.text-center.hide
h2(data-i18n="signup.connected_gplus_header")
p(data-i18n="signup.connected_gplus_p")
#gplus-account-exists-row.row.text-center.hide
h2(data-i18n="signup.gplus_exists")
a.btn.btn-primary#gplus-login-btn(data-i18n="login.log_in")
#facebook-logged-in-row.row.text-center.hide
h2(data-i18n="signup.connected_facebook_header")
p(data-i18n="signup.connected_facebook_p")
#facebook-account-exists-row.row.text-center.hide
h2(data-i18n="signup.facebook_exists")
a.btn.btn-primary#facebook-login-btn(data-i18n="login.log_in")
.row
.col-md-6
.form-group
label.control-label(for="name")
span(data-i18n="general.username")
| :
.input-border
if me.get('name')
input#name.input-large.form-control(name="name", type="text", value=me.get('name'), tabindex=3)
else
input#name.input-large.form-control(name="name", type="text", value="", placeholder="e.g. Alex W the Skater", tabindex=3)
.form-group
label.control-label(for="birthday-input")
span(data-i18n="signup.birthday")
| :
.input-border
select#birthday-month-input.input-large.form-control(name="birthdayMonth", value=view.previousFormInputs.birthdayMonth || '', tabindex=4, style="width: 106px; float: left")
option(value='',data-i18n="calendar.month")
for name, val in ['january','february','march','april','may','june','july','august','september','october','november','december']
option(data-i18n="calendar.#{name}" value=val+1)
select#birthday-day-input.input-large.form-control(name="birthdayDay", value=view.previousFormInputs.birthdayDay || '', tabindex=5, style="width: 75px; float: left")
option(value='',data-i18n="calendar.day")
for dummy, val in new Array(31)
option #{val+1}
select#birthday-year-input.input-large.form-control(name="birthdayYear", value=view.previousFormInputs.birthdayMonth || '', tabindex=6, style="width: 90px;")
option(value='',data-i18n="calendar.year")
- var startYear = new Date().getFullYear() - 1
for dummy, val in new Array(100)
option #{startYear-val}
.form-group
label.control-label(for="school-input")
span.spr(data-i18n="courses.class_code")
em.optional-note
| (
span(data-i18n="signup.optional")
| ):
.input-border
input#class-code-input.input-large.form-control(name="classCode", value=view.previousFormInputs.classCode || '', tabindex=7)
.col-md-6
.form-group.checkbox
label.control-label(for="subscribe")
.input-border
input#subscribe(type="checkbox", checked='checked')
span.custom-checkbox
.glyphicon.glyphicon-ok
span(data-i18n="signup.email_announcements")
.well(data-i18n="signup.hey_students")
input#signup-button.btn.btn-lg.btn-illustrated.btn-block.btn-success(value=translate("signup.sign_up"), type="submit")
.extra-pane
.switch-explanation(data-i18n="signup.login_switch")
.btn.btn-lg.btn-illustrated.btn-warning#switch-to-login-btn(data-i18n="login.log_in")

View file

@ -0,0 +1,105 @@
form#basic-info-form.modal-body.basic-info
.modal-body-content
.auth-network-logins.text-center
h4
span(data-i18n="signup.connect_with")
a#facebook-signup-btn.btn.btn-primary.btn-lg.btn-illustrated.network-login(disabled=!view.signupState.get('facebookEnabled'), data-sso-used="facebook")
img.network-logo(src="/images/pages/modal/auth/facebook_sso_button.png", draggable="false", width="175", height="40")
span.sign-in-blurb(data-i18n="login.sign_in_with_facebook")
a#gplus-signup-btn.btn.btn-danger.btn-lg.btn-illustrated.network-login(disabled=!view.signupState.get('gplusEnabled'), data-sso-used="gplus")
img.network-logo(src="/images/pages/modal/auth/gplus_sso_button.png", draggable="false", width="175", height="40")
span.sign-in-blurb(data-i18n="login.sign_in_with_gplus")
.gplus-login-wrapper
.gplus-login-button
.hr-text
hr
span(data-i18n="general.or")
div.form-container
if ['student', 'teacher'].indexOf(view.signupState.get('path')) !== -1
.row.full-name
.col-xs-offset-3.col-xs-5
.form-group
label.control-label(for="first-name-input")
span(data-i18n="general.first_name")
input#first-name-input.form-control.input-lg(name="firstName")
.col-xs-4
.last-initial.form-group
label.control-label(for="last-name-input")
span(data-i18n="general.last_initial")
input#last-name-input.form-control.input-lg(name="lastName" maxlength="1")
.form-group
.row
.col-xs-5.col-xs-offset-3
label.control-label(for="email-input")
span(data-i18n="share_progress_modal.form_label")
.col-xs-5.col-xs-offset-3
input.form-control.input-lg#email-input(name="email" type="email")
.col-xs-4.email-check
- var checkEmailState = view.state.get('checkEmailState');
if checkEmailState === 'checking'
span.small(data-i18n="signup.checking")
if checkEmailState === 'exists'
span.small
span.text-burgandy.glyphicon.glyphicon-remove-circle
=" "
span(data-i18n="signup.account_exists")
=" "
a.login-link(data-i18n="signup.sign_in")
if checkEmailState === 'available'
span.small
span.text-forest.glyphicon.glyphicon-ok-circle
=" "
span(data-i18n="signup.email_good")
.form-group
.row
.col-xs-7.col-xs-offset-3
label.control-label(for="username-input")
span(data-i18n="general.username")
.col-xs-5.col-xs-offset-3
input.form-control.input-lg#username-input(name="name")
.col-xs-4.name-check
- var checkNameState = view.state.get('checkNameState');
if checkNameState === 'checking'
span.small(data-i18n="signup.checking")
if checkNameState === 'exists'
span.small
span.text-burgandy.glyphicon.glyphicon-remove-circle
=" "
span= view.state.get('suggestedNameText')
if checkNameState === 'available'
span.small
span.text-forest.glyphicon.glyphicon-ok-circle
=" "
span(data-i18n="signup.name_available")
.form-group
.row
.col-xs-7.col-xs-offset-3
label.control-label(for="password-input")
span(data-i18n="general.password")
.col-xs-5.col-xs-offset-3
input.form-control.input-lg#password-input(name="password" type="password")
.form-group.checkbox.subscribe
.row
.col-xs-7.col-xs-offset-3
.checkbox
label
input#subscribe-input(type="checkbox" checked="checked" name="subscribe")
span(data-i18n="signup.email_announcements")
.error-area
- var error = view.state.get('error');
if error
.row
.col-xs-7.col-xs-offset-3
.alert.alert-danger= error
// In reverse order for tabbing purposes
.history-nav-buttons
button#create-account-btn.next-button.btn.btn-lg.btn-navy(type='submit')
span(data-i18n="signup.create_account")
button.back-button.btn.btn-lg.btn-navy-alt(type='button')
span(data-i18n="common.back")

View file

@ -0,0 +1,50 @@
.modal-body-content
h4
span(data-i18n="signup.choose_type")
.path-cards
.path-card.navy
.card-title
span(data-i18n="courses.teacher")
.card-content
h6.card-description
span(data-i18n="signup.teacher_type_1")
ul.small.m-t-1
li
span(data-i18n="signup.teacher_type_2")
li
span(data-i18n="signup.teacher_type_3")
li
span(data-i18n="signup.teacher_type_4")
.card-footer
button.btn.btn-lg.btn-navy.teacher-path-button
.text-h6
span(data-i18n="signup.signup_as_teacher")
.path-card.forest
.card-title
span(data-i18n="courses.student")
.card-content
h6.card-description
span(data-i18n="signup.student_type_1")
ul.small.m-t-1
li
span(data-i18n="signup.student_type_2")
li
span(data-i18n="signup.student_type_3")
li
span(data-i18n="signup.student_type_4")
.card-footer
i.small
span(data-i18n="signup.student_type_5")
button.btn.btn-lg.btn-forest.student-path-button
.text-h6
span(data-i18n="signup.signup_as_student")
.individual-section
.individual-title
span(data-i18n="signup.individuals_or_parents")
p.individual-description.small
span(data-i18n="signup.individual_type")
button.btn.btn-lg.btn-navy.individual-path-button
.text-h6
span(data-i18n="signup.signup_as_individual")

View file

@ -0,0 +1,35 @@
.modal-body
.modal-body-content
h4.m-y-1(data-i18n="signup.account_created")
.text-center.m-y-1
if view.signupState.get('path') === 'student'
p(data-i18n="signup.confirm_student_blurb")
else
p(data-i18n="signup.confirm_individual_blurb")
.signup-info-box-wrapper.m-y-3
.text-burgandy(data-i18n="signup.write_this_down")
.signup-info-box.text-center
if me.get('name')
h4
b
span(data-i18n="general.username")
| : #{me.get('name')}
if me.get('email')
h5
b
- var ssoUsed = view.signupState.get('ssoUsed');
if ssoUsed === 'facebook'
img.m-r-1(src="/images/pages/modal/auth/facebook_small.png")
= me.get('email')
else if ssoUsed === 'gplus'
img.m-r-1(src="/images/pages/modal/auth/gplus_small.png")
= me.get('email')
else
span(data-i18n="general.email")
| : #{me.get('email')}
button#start-btn.btn.btn-navy.btn-lg.m-y-3(data-i18n="signup.start_playing")

View file

@ -0,0 +1,33 @@
form.modal-body.coppa-deny
.modal-body-content
.parent-email-input-group.form-group
if !view.state.get('parentEmailSent')
label.control-label.text-h4(for="parent-email-input")
span(data-i18n="signup.enter_parent_email")
input#parent-email-input(type="email" name="parentEmail" value=state.get('parentEmail'))
if state.get('error')
p.small.error
span(data-i18n="signup.parent_email_error")
p.small.parent-email-blurb.render
span
!= translate('signup.parent_email_blurb').replace('{{email_link}}', '<a href="mailto:team@codecombat.com">team@codecombat.com</a>')
if view.state.get('parentEmailSent')
p.small.parent-email-blurb
span(data-i18n="signup.parent_email_sent")
a.btn.btn-navy.btn-lg(href="/play" data-dismiss="modal") Play without saving
// In reverse order for tabbing purposes
.history-nav-buttons
button.send-parent-email-button.btn.btn-lg.btn-navy(type='submit', disabled=state.get('parentEmailSent') || state.get('parentEmailSending'))
if state.get('parentEmailSent')
span(data-i18n="common.sent")
else
span(data-i18n="common.send")
button.back-btn.btn.btn-lg.btn-navy-alt(type='button')
span(data-i18n="common.back")

View file

@ -0,0 +1,60 @@
extends /templates/core/modal-base-flat
block modal-header
//-
This allows for the header color to switch without the subview templates
needing to contain the header
.modal-header(class=view.signupState.get('path'))
span.glyphicon.glyphicon-remove.button.close(data-dismiss="modal", aria-hidden="true")
+modal-header-content
mixin modal-header-content
h3
case view.signupState.get('path')
when 'student'
span(data-i18n="signup.create_student_header")
when 'teacher'
span(data-i18n="signup.create_teacher_header")
when 'individual'
span(data-i18n="signup.create_individual_header")
default
span(data-i18n="signup.create_header")
//-
This is where the subviews (screens) are hooked up.
Most subview templates have a .modal-body at their root, but this is inconsistent and needs organization.
block modal-body
case view.signupState.get('screen')
when 'choose-account-type'
#choose-account-type-view
when 'segment-check'
#segment-check-view
when 'basic-info'
#basic-info-view
when 'coppa-deny'
#coppa-deny-view
when 'sso-already-exists'
#single-sign-on-already-exists-view
when 'sso-confirm'
#single-sign-on-confirm-view
when 'confirmation'
#confirmation-view
//- This is not yet implemented
//- when 'extras'
//- #extras-view
block modal-footer
//-
This allows for the footer color to switch without the subview templates
needing to contain the footer
.modal-footer(class=view.signupState.get('path'))
+modal-footer-content
mixin modal-footer-content
if view.signupState.get('screen') !== 'confirmation'
.modal-footer-content
.small-details
span.spr(data-i18n="signup.login_switch")
a.login-link
span(data-i18n="signup.sign_in")

View file

@ -0,0 +1,69 @@
form.modal-body.segment-check
.modal-body-content
case view.signupState.get('path')
when 'student'
span(data-i18n="signup.enter_class_code")
.class-code-input-group.form-group
input.class-code-input(name="classCode" value=view.signupState.get('classCode'))
.render
unless _.isEmpty(view.signupState.get('classCode'))
if state.get('classCodeValid')
span.glyphicon.glyphicon-ok-circle.class-code-valid-icon
else
span.glyphicon.glyphicon-remove-circle.class-code-valid-icon
p.render
if _.isEmpty(view.signupState.get('classCode'))
span(data-i18n="signup.ask_teacher_1")
else if state.get('classCodeValid')
span.small(data-i18n="signup.about_to_join")
br
span.classroom-name= view.classroom.get('name')
br
span.teacher-name= view.classroom.owner.get('name')
else
span(data-i18n="signup.classroom_not_found")
if _.isEmpty(view.signupState.get('classCode')) || !state.get('classCodeValid')
br
span.spr(data-i18n="signup.ask_teacher_2")
a.individual-path-button
span(data-i18n="signup.ask_teacher_3")
span.spl(data-i18n="signup.ask_teacher_4")
when 'teacher'
// TODO
when 'individual'
.birthday-form-group.form-group
span(data-i18n="signup.enter_birthdate")
.input-border
select#birthday-month-input.input-large.form-control(name="birthdayMonth", style="width: 106px; float: left")
option(value='',data-i18n="calendar.month")
for name, index in ['january','february','march','april','may','june','july','august','september','october','november','december']
- var month = index + 1
option(data-i18n="calendar.#{name}" value=month, selected=(month == view.signupState.get('birthdayMonth')))
select#birthday-day-input.input-large.form-control(name="birthdayDay", style="width: 75px; float: left")
option(value='',data-i18n="calendar.day")
for day in _.range(1,32)
option(selected=(day == view.signupState.get('birthdayDay'))) #{day}
select#birthday-year-input.input-large.form-control(name="birthdayYear", style="width: 90px;")
option(value='',data-i18n="calendar.year")
- var thisYear = new Date().getFullYear()
for year in _.range(thisYear, thisYear - 100, -1)
option(selected=(year == view.signupState.get('birthdayYear'))) #{year}
default
p
span Sign-up error, please contact
=" "
a(href="mailto:support@codecombat.com") support@codecombat.com
| .
// In reverse order for tabbing purposes
.history-nav-buttons
//- disabled=!view.signupState.get('segmentCheckValid')
button.next-button.btn.btn-lg.btn-navy(type='submit')
span(data-i18n="about.next")
button.back-to-account-type.btn.btn-lg.btn-navy-alt(type='button')
span(data-i18n="common.back")

View file

@ -0,0 +1,19 @@
.modal-body
.modal-body-content
if view.signupState.get('ssoUsed')
h4
span(data-i18n="signup.account_exists")
div.small
b
span= view.signupState.get('email')
.hr-text
hr
span(data-i18n="common.continue")
button.login-link.btn.btn-lg.btn-navy
span(data-i18n="login.log_in")
.history-nav-buttons.just-one
button.back-button.btn.btn-lg.btn-navy-alt(type='button')
span(data-i18n="common.back")

View file

@ -0,0 +1,57 @@
form#basic-info-form.modal-body
.modal-body-content
h4
span(data-i18n="signup.sso_connected")
div.small.m-y-1
- var ssoUsed = view.signupState.get('ssoUsed');
if ssoUsed === 'facebook'
img(src="/images/pages/modal/auth/facebook_small.png")
if ssoUsed === 'gplus'
img(src="/images/pages/modal/auth/gplus_small.png")
b.m-x-1
span= view.signupState.get('email')
span.glyphicon.glyphicon-ok-circle.class-code-valid-icon
.hr-text.m-y-3
hr
span(data-i18n="common.continue")
.form-container
input.hidden(name="email" value=view.signupState.get('email'))
.form-group
.row
.col-xs-7.col-xs-offset-3
label.control-label(for="username-input")
span(data-i18n="general.username")
.col-xs-5.col-xs-offset-3
input.form-control.input-lg#username-input(name="name")
.col-xs-4.name-check
- var checkNameState = view.state.get('checkNameState');
if checkNameState === 'checking'
span.small(data-i18n="signup.checking")
if checkNameState === 'exists'
span.small
span.text-burgandy.glyphicon.glyphicon-remove-circle
=" "
span= view.state.get('suggestedNameText')
if checkNameState === 'available'
span.small
span.text-forest.glyphicon.glyphicon-ok-circle
=" "
span(data-i18n="signup.name_available")
.form-group.subscribe
.row
.col-xs-7.col-xs-offset-3
.checkbox
label
input#subscribe-input(type="checkbox" checked="checked" name="subscribe")
span(data-i18n="signup.email_announcements")
// In reverse order for tabbing purposes
.history-nav-buttons
button.next-button.btn.btn-lg.btn-navy(type='submit')
span(data-i18n="signup.create_account")
button.back-button.btn.btn-lg.btn-navy-alt(type='button')
span(data-i18n="common.back")

View file

@ -36,6 +36,9 @@ block modal-footer-content
button.btn.btn-sm#agreement-button(data-i18n="versions.cla_agree") I AGREE
if view.isPatch
.alert.alert-info(data-i18n="versions.owner_approve") An owner will need to approve it before your changes will become visible.
.save-error-area
if view.savingPatchError
.alert.alert-danger Unable to save patch: #{view.savingPatchError}
.buttons
button.btn(data-dismiss="modal", data-i18n="common.cancel") Cancel

View file

@ -7,7 +7,7 @@ block content
if view.leagueType === 'course' && view.course
#course-header
#course-details-link
a(href="/courses/{#view.course.id}/{#view.league.id}")
a(href="/courses/#{view.course.id}/#{view.league.id}")
span.glyphicon.glyphicon-arrow-left
span.spl Levels

View file

@ -110,6 +110,14 @@ module.exports = class NewHomeView extends RootView
$(window).on 'resize', @fitToPage
@fitToPage()
setTimeout(@fitToPage, 0)
if me.isAnonymous()
CreateAccountModal = require 'views/core/CreateAccountModal/CreateAccountModal'
if document.location.hash is '#create-account'
@openModalView(new CreateAccountModal())
if document.location.hash is '#create-account-individual'
@openModalView(new CreateAccountModal({startOnPath: 'individual'}))
if document.location.hash is '#create-account-student'
@openModalView(new CreateAccountModal({startOnPath: 'student'}))
super()
destroy: ->

View file

@ -68,14 +68,13 @@ module.exports = TestView = class TestView extends RootView
specDone: (result) ->
if result.status is 'failed'
console.log 'result', result
report = {
suiteDescriptions: _.clone(@suiteStack)
failMessages: (fe.message for fe in result.failedExpectations)
testDescription: result.description
}
view.failureReports.push(report)
view.renderSelectors('#failure-reports')
view?.failureReports.push(report)
view?.renderSelectors('#failure-reports')
suiteStarted: (result) ->
@suiteStack.push(result.description)

View file

@ -4,7 +4,14 @@ RootView = require 'views/core/RootView'
template = require 'templates/admin'
AdministerUserModal = require 'views/admin/AdministerUserModal'
forms = require 'core/forms'
Campaigns = require 'collections/Campaigns'
Classroom = require 'models/Classroom'
CocoCollection = require 'collections/CocoCollection'
Course = require 'models/Course'
LevelSessions = require 'collections/LevelSessions'
User = require 'models/User'
Users = require 'collections/Users'
module.exports = class MainAdminView extends RootView
id: 'admin-view'
@ -19,14 +26,37 @@ module.exports = class MainAdminView extends RootView
'click #user-search-result': 'onClickUserSearchResult'
'click #create-free-sub-btn': 'onClickFreeSubLink'
'click #terminal-create': 'onClickTerminalSubLink'
'click .classroom-progress-csv': 'onClickExportProgress'
getTitle: -> return $.i18n.t('account_settings.admin')
initialize: ->
@campaigns = new Campaigns()
@courses = new CocoCollection([], { url: "/db/course", model: Course})
if window.amActually
@amActually = new User({_id: window.amActually})
@amActually.fetch()
@supermodel.trackModel(@amActually)
if me.isAdmin()
@supermodel.trackRequest @campaigns.fetchByType('course', { data: { project: 'levels' } })
@supermodel.loadCollection(@courses, 'courses')
super()
onLoaded: ->
campaignCourseIndexMap = {}
for course, index in @courses.models
campaignCourseIndexMap[course.get('campaignID')] = index + 1
@courseLevels = []
for campaign in @campaigns.models
continue unless campaignCourseIndexMap[campaign.id]
for levelID, level of campaign.get('levels')
@courseLevels.push({
levelID
slug: level.slug
courseIndex: campaignCourseIndexMap[campaign.id]
})
super()
onClickStopSpyingButton: ->
button = @$('#stop-spying-btn')
@ -126,3 +156,85 @@ module.exports = class MainAdminView extends RootView
console.error 'Failed to create prepaid', response
@supermodel.addRequestResource('create_prepaid', options, 0).load()
onClickExportProgress: ->
return unless @courseLevels?.length > 0
$('.classroom-progress-csv').prop('disabled', true)
classCode = $('.classroom-progress-class-code').val()
userMap = {}
new Promise((resolve, reject) =>
new Classroom().fetchByCode(classCode, {
success: resolve
error: (model, response, options) => reject(response)
})
)
.then (classroom) =>
new Promise((resolve, reject) =>
new Classroom({ _id: classroom.id }).fetch({
success: resolve
error: (model, response, options) => reject(response)
})
)
.then (classroom) =>
new Promise((resolve, reject) =>
new Users().fetchForClassroom(classroom, {
success: (models, response, options) =>
resolve([classroom, models]) if models?.loaded
error: (models, response, options) => reject(response)
})
)
.then ([classroom, users]) =>
userMap[user.id] = user for user in users.models
new Promise((resolve, reject) =>
new LevelSessions().fetchForAllClassroomMembers(classroom, {
success: (models, response, options) =>
resolve(models) if models?.loaded
error: (models, response, options) => reject(response)
})
)
.then (sessions) =>
userLevelPlaytimeMap = {}
for session in sessions.models
continue unless session.get('state')?.complete
levelID = session.get('level').original
userID = session.get('creator')
userLevelPlaytimeMap[userID] ?= {}
userLevelPlaytimeMap[userID][levelID] ?= {}
userLevelPlaytimeMap[userID][levelID] = session.get('playtime')
userPlaytimes = []
for userID, user of userMap
playtimes = [user.get('name') ? 'Anonymous']
for level in @courseLevels
if userLevelPlaytimeMap[userID]?[level.levelID]?
rawSeconds = parseInt(userLevelPlaytimeMap[userID][level.levelID])
hours = Math.floor(rawSeconds / 60 / 60)
minutes = Math.floor(rawSeconds / 60 - hours * 60)
seconds = Math.round(rawSeconds - hours * 60 - minutes * 60)
hours = "0#{hours}" if hours < 10
minutes = "0#{minutes}" if minutes < 10
seconds = "0#{seconds}" if seconds < 10
playtimes.push "#{hours}:#{minutes}:#{seconds}"
else
playtimes.push 'Incomplete'
userPlaytimes.push(playtimes)
columnLabels = "Username"
currentLevel = 1
lastCourseIndex = 1
for level in @courseLevels
unless level.courseIndex is lastCourseIndex
currentLevel = 1
lastCourseIndex = level.courseIndex
columnLabels += ",CS#{level.courseIndex}.#{currentLevel++} #{level.slug}"
csvContent = "data:text/csv;charset=utf-8,#{columnLabels}\n"
for studentRow in userPlaytimes
csvContent += studentRow.join(',') + "\n"
csvContent = csvContent.substring(0, csvContent.length - 1)
encodedUri = encodeURI(csvContent)
window.open(encodedUri)
$('.classroom-progress-csv').prop('disabled', false)
.catch (error) ->
$('.classroom-progress-csv').prop('disabled', false)
console.error error

View file

@ -22,6 +22,7 @@ module.exports = class AuthModal extends ModalView
initialize: (options={}) ->
@previousFormInputs = options.initialValues or {}
@previousFormInputs.emailOrUsername ?= @previousFormInputs.email or @previousFormInputs.username
# TODO: Switch to promises and state, rather than using defer to hackily enable buttons after render
application.gplusHandler.loadAPI({ success: => _.defer => @$('#gplus-login-btn').attr('disabled', false) })

View file

@ -1,12 +0,0 @@
ModalView = require 'views/core/ModalView'
template = require 'templates/core/coppa-deny'
module.exports = class COPPADenyModal extends ModalView
id: 'coppa-deny-modal'
template: template
closeButton: true
constructor: ->
super()
window.tracker?.trackEvent 'COPPA Message Shown', category: 'Homepage'

View file

@ -14,6 +14,7 @@ doNothing = ->
module.exports = class CocoView extends Backbone.View
cache: false # signals to the router to keep this view around
retainSubviews: false # set to true if you don't want subviews to be destroyed whenever the view renders
template: -> ''
events:
@ -108,12 +109,19 @@ module.exports = class CocoView extends Backbone.View
render: ->
return @ unless me
view.destroy() for id, view of @subviews
if @retainSubviews
oldSubviews = _.values(@subviews)
else
view.destroy() for id, view of @subviews
@subviews = {}
super()
return @template if _.isString(@template)
@$el.html @template(@getRenderData())
if @retainSubviews
for view in oldSubviews
@insertSubView(view)
if not @supermodel.finished()
@showLoading()
else
@ -306,11 +314,20 @@ module.exports = class CocoView extends Backbone.View
key = @makeSubViewKey(view)
@subviews[key].destroy() if key of @subviews
elToReplace ?= @$el.find('#'+view.id)
elToReplace.after(view.el).remove()
@registerSubView(view, key)
view.render()
view.afterInsert()
view
if @retainSubviews
@registerSubView(view, key)
if elToReplace[0]
view.setElement(elToReplace[0])
view.render()
view.afterInsert()
return view
else
elToReplace.after(view.el).remove()
@registerSubView(view, key)
view.render()
view.afterInsert()
return view
registerSubView: (view, key) ->
# used to register views which are custom inserted into the view,

View file

@ -1,259 +0,0 @@
ModalView = require 'views/core/ModalView'
template = require 'templates/core/create-account-modal'
forms = require 'core/forms'
User = require 'models/User'
application = require 'core/application'
Classroom = require 'models/Classroom'
errors = require 'core/errors'
COPPADenyModal = require 'views/core/COPPADenyModal'
utils = require 'core/utils'
module.exports = class CreateAccountModal extends ModalView
id: 'create-account-modal'
template: template
events:
'submit form': 'onSubmitForm'
'keyup #name': 'onNameChange'
'click #gplus-signup-btn': 'onClickGPlusSignupButton'
'click #gplus-login-btn': 'onClickGPlusLoginButton'
'click #facebook-signup-btn': 'onClickFacebookSignupButton'
'click #facebook-login-btn': 'onClickFacebookLoginButton'
'click #close-modal': 'hide'
'click #switch-to-login-btn': 'onClickSwitchToLoginButton'
# Initialization
initialize: (options={}) ->
@onNameChange = _.debounce(_.bind(@checkNameExists, @), 500)
options.initialValues ?= {}
options.initialValues?.classCode ?= utils.getQueryVariable('_cc', "")
@previousFormInputs = options.initialValues or {}
# TODO: Switch to promises and state, rather than using defer to hackily enable buttons after render
application.gplusHandler.loadAPI({ success: => _.defer => @$('#gplus-signup-btn').attr('disabled', false) })
application.facebookHandler.loadAPI({ success: => _.defer => @$('#facebook-signup-btn').attr('disabled', false) })
afterRender: ->
super()
@playSound 'game-menu-open'
afterInsert: ->
super()
_.delay (=> $('input:visible:first', @$el).focus()), 500
# User creation
onSubmitForm: (e) ->
e.preventDefault()
@playSound 'menu-button-click'
forms.clearFormAlerts(@$el)
attrs = forms.formToObject @$el
attrs.name = @suggestedName if @suggestedName
_.defaults attrs, me.pick([
'preferredLanguage', 'testGroupNumber', 'dateCreated', 'wizardColor1',
'name', 'music', 'volume', 'emails', 'schoolName'
])
attrs.emails ?= {}
attrs.emails.generalNews ?= {}
attrs.emails.generalNews.enabled = @$el.find('#subscribe').prop('checked')
@classCode = attrs.classCode
delete attrs.classCode
error = false
birthday = new Date Date.UTC attrs.birthdayYear, attrs.birthdayMonth - 1, attrs.birthdayDay
if @classCode
attrs.role = 'student'
else if isNaN(birthday.getTime())
forms.setErrorToProperty @$el, 'birthdayDay', 'Required'
error = true
else
age = (new Date().getTime() - birthday.getTime()) / 365.4 / 24 / 60 / 60 / 1000
attrs.birthday = birthday.toISOString()
delete attrs.birthdayYear
delete attrs.birthdayMonth
delete attrs.birthdayDay
_.assign attrs, @gplusAttrs if @gplusAttrs
_.assign attrs, @facebookAttrs if @facebookAttrs
res = tv4.validateMultiple attrs, User.schema
if not res.valid
forms.applyErrorsToForm(@$el, res.errors)
error = true
if not _.any([attrs.password, @gplusAttrs, @facebookAttrs])
forms.setErrorToProperty @$el, 'password', 'Required'
error = true
if not forms.validateEmail(attrs.email)
forms.setErrorToProperty @$el, 'email', 'Please enter a valid email address'
error = true
return if error
@$('#signup-button').text($.i18n.t('signup.creating')).attr('disabled', true)
@newUser = new User(attrs)
if @classCode
@signupClassroomPrecheck()
else
if age < 13
@openModalView new COPPADenyModal
else
@createUser()
signupClassroomPrecheck: ->
classroom = new Classroom()
classroom.fetch({ data: { code: @classCode } })
classroom.once 'sync', @createUser, @
classroom.once 'error', @onClassroomFetchError, @
onClassroomFetchError: ->
@$('#signup-button').text($.i18n.t('signup.sign_up')).attr('disabled', false)
forms.setErrorToProperty(@$el, 'classCode', "#{@classCode} is not a valid code. Please verify the code is typed correctly.")
@$('#class-code-input').val('')
createUser: ->
options = {}
window.tracker?.identify()
if @gplusAttrs
@newUser.set('_id', me.id)
options.url = "/db/user?gplusID=#{@gplusAttrs.gplusID}&gplusAccessToken=#{application.gplusHandler.accessToken.access_token}"
options.type = 'PUT'
if @facebookAttrs
@newUser.set('_id', me.id)
options.url = "/db/user?facebookID=#{@facebookAttrs.facebookID}&facebookAccessToken=#{application.facebookHandler.authResponse.accessToken}"
options.type = 'PUT'
@newUser.save(null, options)
@newUser.once 'sync', @onUserCreated, @
@newUser.once 'error', @onUserSaveError, @
onUserSaveError: (user, jqxhr) ->
@$('#signup-button').text($.i18n.t('signup.sign_up')).attr('disabled', false)
if _.isObject(jqxhr.responseJSON) and jqxhr.responseJSON.property
error = jqxhr.responseJSON
if jqxhr.status is 409 and error.property is 'name'
@newUser.unset 'name'
return @createUser()
return forms.applyErrorsToForm(@$el, [jqxhr.responseJSON])
errors.showNotyNetworkError(jqxhr)
onUserCreated: ->
Backbone.Mediator.publish "auth:signed-up", {}
if @gplusAttrs
window.tracker?.trackEvent 'Google Login', category: "Signup", label: 'GPlus'
window.tracker?.trackEvent 'Finished Signup', category: "Signup", label: 'GPlus'
else if @facebookAttrs
window.tracker?.trackEvent 'Facebook Login', category: "Signup", label: 'Facebook'
window.tracker?.trackEvent 'Finished Signup', category: "Signup", label: 'Facebook'
else
window.tracker?.trackEvent 'Finished Signup', category: "Signup", label: 'CodeCombat'
if @classCode
url = "/courses?_cc="+@classCode
location.href = url
else
window.location.reload()
# Google Plus
onClickGPlusSignupButton: ->
btn = @$('#gplus-signup-btn')
application.gplusHandler.connect({
context: @
success: ->
btn.find('.sign-in-blurb').text($.i18n.t('signup.creating'))
btn.attr('disabled', true)
application.gplusHandler.loadPerson({
context: @
success: (@gplusAttrs) ->
existingUser = new User()
existingUser.fetchGPlusUser(@gplusAttrs.gplusID, {
context: @
complete: ->
@$('#email-password-row').remove()
success: =>
@$('#gplus-account-exists-row').removeClass('hide')
error: (user, jqxhr) =>
if jqxhr.status is 404
@$('#gplus-logged-in-row').toggleClass('hide')
else
errors.showNotyNetworkError(jqxhr)
})
})
})
onClickGPlusLoginButton: ->
me.loginGPlusUser(@gplusAttrs.gplusID, {
context: @
success: -> window.location.reload()
error: ->
@$('#gplus-login-btn').text($.i18n.t('login.log_in')).attr('disabled', false)
errors.showNotyNetworkError(arguments...)
})
@$('#gplus-login-btn').text($.i18n.t('login.logging_in')).attr('disabled', true)
# Facebook
onClickFacebookSignupButton: ->
btn = @$('#facebook-signup-btn')
application.facebookHandler.connect({
context: @
success: ->
btn.find('.sign-in-blurb').text($.i18n.t('signup.creating'))
btn.attr('disabled', true)
application.facebookHandler.loadPerson({
context: @
success: (@facebookAttrs) ->
existingUser = new User()
existingUser.fetchFacebookUser(@facebookAttrs.facebookID, {
context: @
complete: ->
@$('#email-password-row').remove()
success: =>
@$('#facebook-account-exists-row').removeClass('hide')
error: (user, jqxhr) =>
if jqxhr.status is 404
@$('#facebook-logged-in-row').toggleClass('hide')
else
errors.showNotyNetworkError(jqxhr)
})
})
})
onClickFacebookLoginButton: ->
me.loginFacebookUser(@facebookAttrs.facebookID, {
context: @
success: -> window.location.reload()
error: =>
@$('#facebook-login-btn').text($.i18n.t('login.log_in')).attr('disabled', false)
errors.showNotyNetworkError(jqxhr)
})
@$('#facebook-login-btn').text($.i18n.t('login.logging_in')).attr('disabled', true)
# Misc
onHidden: ->
super()
@playSound 'game-menu-close'
checkNameExists: ->
name = $('#name', @$el).val()
return forms.clearFormAlerts(@$el) if name is ''
User.getUnconflictedName name, (newName) =>
forms.clearFormAlerts(@$el)
if name is newName
@suggestedName = undefined
else
@suggestedName = newName
forms.setErrorToProperty @$el, 'name', "That name is taken! How about #{newName}?"
onClickSwitchToLoginButton: ->
AuthModal = require('./AuthModal')
modal = new AuthModal({initialValues: forms.formToObject @$el})
currentView.openModalView(modal)

View file

@ -0,0 +1,253 @@
CocoView = require 'views/core/CocoView'
AuthModal = require 'views/core/AuthModal'
template = require 'templates/core/create-account-modal/basic-info-view'
forms = require 'core/forms'
errors = require 'core/errors'
User = require 'models/User'
State = require 'models/State'
###
This view handles the primary form for user details name, email, password, etc,
and the AJAX that actually creates the user.
It also handles facebook/g+ login, which if used, open one of two other screens:
sso-already-exists: If the facebook/g+ connection is already associated with a user, they're given a log in button
sso-confirm: If this is a new facebook/g+ connection, ask for a username, then allow creation of a user
The sso-confirm view *inherits from this view* in order to share its account-creation logic and events.
This means the selectors used in these events must work in both templates.
This view currently uses the old form API instead of stateful render.
It needs some work to make error UX and rendering better, but is functional.
###
module.exports = class BasicInfoView extends CocoView
id: 'basic-info-view'
template: template
events:
'change input[name="email"]': 'onChangeEmail'
'change input[name="name"]': 'onChangeName'
'click .back-button': 'onClickBackButton'
'submit form': 'onSubmitForm'
'click .use-suggested-name-link': 'onClickUseSuggestedNameLink'
'click #facebook-signup-btn': 'onClickSsoSignupButton'
'click #gplus-signup-btn': 'onClickSsoSignupButton'
initialize: ({ @signupState } = {}) ->
@state = new State {
suggestedNameText: '...'
checkEmailState: 'standby' # 'checking', 'exists', 'available'
checkEmailValue: null
checkEmailPromise: null
checkNameState: 'standby' # same
checkNameValue: null
checkNamePromise: null
error: ''
}
@listenTo @state, 'change:checkEmailState', -> @renderSelectors('.email-check')
@listenTo @state, 'change:checkNameState', -> @renderSelectors('.name-check')
@listenTo @state, 'change:error', -> @renderSelectors('.error-area')
@listenTo @signupState, 'change:facebookEnabled', -> @renderSelectors('.auth-network-logins')
@listenTo @signupState, 'change:gplusEnabled', -> @renderSelectors('.auth-network-logins')
onChangeEmail: ->
@checkEmail()
checkEmail: ->
email = @$('[name="email"]').val()
if email is @state.get('checkEmailValue')
return @state.get('checkEmailPromise')
if not (email and forms.validateEmail(email))
@state.set({
checkEmailState: 'standby'
checkEmailValue: email
checkEmailPromise: null
})
return Promise.resolve()
@state.set({
checkEmailState: 'checking'
checkEmailValue: email
checkEmailPromise: (User.checkEmailExists(email)
.then ({exists}) =>
return unless email is @$('[name="email"]').val()
if exists
@state.set('checkEmailState', 'exists')
else
@state.set('checkEmailState', 'available')
.catch (e) =>
@state.set('checkEmailState', 'standby')
throw e
)
})
return @state.get('checkEmailPromise')
onChangeName: ->
@checkName()
checkName: ->
name = @$('input[name="name"]').val()
if name is @state.get('checkNameValue')
return @state.get('checkNamePromise')
if not name
@state.set({
checkNameState: 'standby'
checkNameValue: name
checkNamePromise: null
})
return Promise.resolve()
@state.set({
checkNameState: 'checking'
checkNameValue: name
checkNamePromise: (User.checkNameConflicts(name)
.then ({ suggestedName, conflicts }) =>
return unless name is @$('input[name="name"]').val()
if conflicts
suggestedNameText = $.i18n.t('signup.name_taken').replace('{{suggestedName}}', suggestedName)
@state.set({ checkNameState: 'exists', suggestedNameText })
else
@state.set { checkNameState: 'available' }
.catch (error) =>
@state.set('checkNameState', 'standby')
throw error
)
})
return @state.get('checkNamePromise')
checkBasicInfo: (data) ->
# TODO: Move this to somewhere appropriate
tv4.addFormat({
'email': (email) ->
if forms.validateEmail(email)
return null
else
return {code: tv4.errorCodes.FORMAT_CUSTOM, message: "Please enter a valid email address."}
})
forms.clearFormAlerts(@$el)
res = tv4.validateMultiple data, @formSchema()
forms.applyErrorsToForm(@$('form'), res.errors) unless res.valid
return res.valid
formSchema: ->
type: 'object'
properties:
email: User.schema.properties.email
name: User.schema.properties.name
password: User.schema.properties.password
required: ['email', 'name', 'password'].concat (if @signupState.get('path') is 'student' then ['firstName', 'lastName'] else [])
onClickBackButton: -> @trigger 'nav-back'
onClickUseSuggestedNameLink: (e) ->
@$('input[name="name"]').val(@state.get('suggestedName'))
forms.clearFormAlerts(@$el.find('input[name="name"]').closest('.form-group').parent())
onSubmitForm: (e) ->
@state.unset('error')
e.preventDefault()
data = forms.formToObject(e.currentTarget)
valid = @checkBasicInfo(data)
return unless valid
@displayFormSubmitting()
AbortError = new Error()
@checkEmail()
.then @checkName()
.then =>
if not (@state.get('checkEmailState') is 'available' and @state.get('checkNameState') is 'available')
throw AbortError
# update User
emails = _.assign({}, me.get('emails'))
emails.generalNews ?= {}
emails.generalNews.enabled = @$('#subscribe-input').is(':checked')
me.set('emails', emails)
unless _.isNaN(@signupState.get('birthday').getTime())
me.set('birthday', @signupState.get('birthday').toISOString())
me.set(_.omit(@signupState.get('ssoAttrs') or {}, 'email', 'facebookID', 'gplusID'))
me.set('name', @$('input[name="name"]').val())
jqxhr = me.save()
if not jqxhr
console.error(me.validationError)
throw new Error('Could not save user')
return new Promise(jqxhr.then)
.then =>
# Use signup method
window.tracker?.identify()
switch @signupState.get('ssoUsed')
when 'gplus'
{ email, gplusID } = @signupState.get('ssoAttrs')
jqxhr = me.signupWithGPlus(email, gplusID)
when 'facebook'
{ email, facebookID } = @signupState.get('ssoAttrs')
jqxhr = me.signupWithFacebook(email, facebookID)
else
{ email, password } = forms.formToObject(@$el)
jqxhr = me.signupWithPassword(email, password)
return new Promise(jqxhr.then)
.then =>
{ classCode, classroom } = @signupState.attributes
if classCode and classroom
return new Promise(classroom.joinWithCode(classCode).then)
.then =>
@finishSignup()
.catch (e) =>
@displayFormStandingBy()
if e is AbortError
return
else
console.error 'BasicInfoView form submission Promise error:', e
@state.set('error', e.responseJSON?.message or 'Unknown Error')
finishSignup: ->
@trigger 'signup'
displayFormSubmitting: ->
@$('#create-account-btn').text($.i18n.t('signup.creating')).attr('disabled', true)
@$('input').attr('disabled', true)
displayFormStandingBy: ->
@$('#create-account-btn').text($.i18n.t('signup.create_account')).attr('disabled', false)
@$('input').attr('disabled', false)
onClickSsoSignupButton: (e) ->
e.preventDefault()
ssoUsed = $(e.currentTarget).data('sso-used')
handler = if ssoUsed is 'facebook' then application.facebookHandler else application.gplusHandler
handler.connect({
context: @
success: ->
handler.loadPerson({
context: @
success: (ssoAttrs) ->
@signupState.set { ssoAttrs }
{ email } = ssoAttrs
User.checkEmailExists(email).then ({exists}) =>
@signupState.set {
ssoUsed
email: ssoAttrs.email
}
if exists
@trigger 'sso-connect:already-in-use'
else
@trigger 'sso-connect:new-user'
})
})

View file

@ -0,0 +1,11 @@
CocoView = require 'views/core/CocoView'
template = require 'templates/core/create-account-modal/choose-account-type-view'
module.exports = class ChooseAccountTypeView extends CocoView
id: 'choose-account-type-view'
template: template
events:
'click .teacher-path-button': -> @trigger 'choose-path', 'teacher'
'click .student-path-button': -> @trigger 'choose-path', 'student'
'click .individual-path-button': -> @trigger 'choose-path', 'individual'

View file

@ -0,0 +1,23 @@
CocoView = require 'views/core/CocoView'
State = require 'models/State'
template = require 'templates/core/create-account-modal/confirmation-view'
forms = require 'core/forms'
module.exports = class ConfirmationView extends CocoView
id: 'confirmation-view'
template: template
events:
'click #start-btn': 'onClickStartButton'
initialize: ({ @signupState } = {}) ->
onClickStartButton: ->
classroom = @signupState.get('classroom')
if @signupState.get('path') is 'student'
# force clearing of _cc GET param from url if on /courses
application.router.navigate('/', {replace: true})
application.router.navigate('/courses')
else
application.router.navigate('/play')
document.location.reload()

View file

@ -0,0 +1,33 @@
CocoView = require 'views/core/CocoView'
State = require 'models/State'
template = require 'templates/core/create-account-modal/coppa-deny-view'
forms = require 'core/forms'
contact = require 'core/contact'
module.exports = class CoppaDenyView extends CocoView
id: 'coppa-deny-view'
template: template
events:
'click .send-parent-email-button': 'onClickSendParentEmailButton'
'change input[name="parentEmail"]': 'onChangeParentEmail'
'click .back-btn': 'onClickBackButton'
initialize: ({ @signupState } = {}) ->
@state = new State({ parentEmail: '' })
@listenTo @state, 'all', _.debounce(@render)
onChangeParentEmail: (e) ->
@state.set { parentEmail: $(e.currentTarget).val() }, { silent: true }
onClickSendParentEmailButton: (e) ->
e.preventDefault()
@state.set({ parentEmailSending: true })
contact.sendParentSignupInstructions(@state.get('parentEmail'))
.then =>
@state.set({ error: false, parentEmailSent: true, parentEmailSending: false })
.catch =>
@state.set({ error: true, parentEmailSent: false, parentEmailSending: false })
onClickBackButton: ->
@trigger 'nav-back'

View file

@ -0,0 +1,116 @@
ModalView = require 'views/core/ModalView'
AuthModal = require 'views/core/AuthModal'
ChooseAccountTypeView = require './ChooseAccountTypeView'
SegmentCheckView = require './SegmentCheckView'
CoppaDenyView = require './CoppaDenyView'
BasicInfoView = require './BasicInfoView'
SingleSignOnAlreadyExistsView = require './SingleSignOnAlreadyExistsView'
SingleSignOnConfirmView = require './SingleSignOnConfirmView'
ConfirmationView = require './ConfirmationView'
State = require 'models/State'
template = require 'templates/core/create-account-modal/create-account-modal'
forms = require 'core/forms'
User = require 'models/User'
application = require 'core/application'
errors = require 'core/errors'
utils = require 'core/utils'
###
CreateAccountModal is a wizard-style modal with several subviews, one for each
`screen` that the user navigates forward and back through.
There are three `path`s, one for each account type (individual, student).
Teacher account path will be added later; for now it defers to /teachers/signup)
Each subview handles only one `screen`, but all three `path` variants because
their logic is largely the same.
They `screen`s are:
choose-account-type: Sets the `path`.
segment-check: Checks required info for the path (age, )
coppa-deny: Seen if the indidual segment-check age is < 13 years old
basic-info: This is the form for username/password/email/etc.
It asks for whatever is needed for this type of user.
It also handles the actual user creation.
A user may create their account here, or connect with facebook/g+
sso-confirm: Alternate version of basic-info for new facebook/g+ users
sso-already-exists: When facebook/g+ user already exists, this prompts them to sign in.
extras: Not yet implemented
confirmation: When an account has been successfully created, this view shows them their info and
links them to a landing page based on their account type.
NOTE: BasicInfoView's two children (SingleSignOn...View) inherit from it.
This allows them to have the same form-handling logic, but different templates.
###
module.exports = class CreateAccountModal extends ModalView
id: 'create-account-modal'
template: template
closesOnClickOutside: false
retainSubviews: true
events:
'click .login-link': 'onClickLoginLink'
initialize: (options={}) ->
classCode = utils.getQueryVariable('_cc', undefined)
@signupState = new State {
path: if classCode then 'student' else null
screen: if classCode then 'segment-check' else 'choose-account-type'
ssoUsed: null # or 'facebook', 'gplus'
classroom: null # or Classroom instance
facebookEnabled: application.facebookHandler.apiLoaded
gplusEnabled: application.gplusHandler.apiLoaded
classCode
birthday: new Date('') # so that birthday.getTime() is NaN
}
{ startOnPath } = options
if startOnPath is 'student'
@signupState.set({ path: 'student', screen: 'segment-check' })
if startOnPath is 'individual'
@signupState.set({ path: 'individual', screen: 'segment-check' })
@listenTo @signupState, 'all', _.debounce @render
@listenTo @insertSubView(new ChooseAccountTypeView()),
'choose-path': (path) ->
if path is 'teacher'
application.router.navigate('/teachers/signup', trigger: true)
else
@signupState.set { path, screen: 'segment-check' }
@listenTo @insertSubView(new SegmentCheckView({ @signupState })),
'choose-path': (path) -> @signupState.set { path, screen: 'segment-check' }
'nav-back': -> @signupState.set { path: null, screen: 'choose-account-type' }
'nav-forward': (screen) -> @signupState.set { screen: screen or 'basic-info' }
@listenTo @insertSubView(new CoppaDenyView({ @signupState })),
'nav-back': -> @signupState.set { screen: 'segment-check' }
@listenTo @insertSubView(new BasicInfoView({ @signupState })),
'sso-connect:already-in-use': -> @signupState.set { screen: 'sso-already-exists' }
'sso-connect:new-user': -> @signupState.set {screen: 'sso-confirm'}
'nav-back': -> @signupState.set { screen: 'segment-check' }
'signup': -> @signupState.set { screen: 'confirmation' }
@listenTo @insertSubView(new SingleSignOnAlreadyExistsView({ @signupState })),
'nav-back': -> @signupState.set { screen: 'basic-info' }
@listenTo @insertSubView(new SingleSignOnConfirmView({ @signupState })),
'nav-back': -> @signupState.set { screen: 'basic-info' }
'signup': -> @signupState.set { screen: 'confirmation' }
@insertSubView(new ConfirmationView({ @signupState }))
# TODO: Switch to promises and state, rather than using defer to hackily enable buttons after render
application.facebookHandler.loadAPI({ success: => @signupState.set { facebookEnabled: true } unless @destroyed })
application.gplusHandler.loadAPI({ success: => @signupState.set { gplusEnabled: true } unless @destroyed })
@once 'hidden', ->
if @signupState.get('screen') is 'confirmation' and not application.testing
# ensure logged in state propagates through the entire app
document.location.reload()
onClickLoginLink: ->
# TODO: Make sure the right information makes its way into the state.
@openModalView(new AuthModal({ initialValues: @signupState.pick(['email', 'name', 'password']) }))

View file

@ -0,0 +1,106 @@
CocoView = require 'views/core/CocoView'
template = require 'templates/core/create-account-modal/segment-check-view'
forms = require 'core/forms'
Classroom = require 'models/Classroom'
State = require 'models/State'
module.exports = class SegmentCheckView extends CocoView
id: 'segment-check-view'
template: template
events:
'click .back-to-account-type': -> @trigger 'nav-back'
'input .class-code-input': 'onInputClassCode'
'input .birthday-form-group': 'onInputBirthday'
'submit form.segment-check': 'onSubmitSegmentCheck'
'click .individual-path-button': -> @trigger 'choose-path', 'individual'
initialize: ({ @signupState } = {}) ->
@checkClassCodeDebounced = _.debounce @checkClassCode, 1000
@fetchClassByCode = _.memoize(@fetchClassByCode)
@classroom = new Classroom()
@state = new State()
if @signupState.get('classCode')
@checkClassCode(@signupState.get('classCode'))
@listenTo @state, 'all', _.debounce(->
@renderSelectors('.render')
@trigger 'special-render'
)
getClassCode: -> @$('.class-code-input').val() or @signupState.get('classCode')
onInputClassCode: ->
@classroom = new Classroom()
forms.clearFormAlerts(@$el)
classCode = @getClassCode()
@signupState.set { classCode }, { silent: true }
@checkClassCodeDebounced()
checkClassCode: ->
return if @destroyed
classCode = @getClassCode()
@fetchClassByCode(classCode)
.then (classroom) =>
return if @destroyed or @getClassCode() isnt classCode
if classroom
@classroom = classroom
@state.set { classCodeValid: true, segmentCheckValid: true }
else
@classroom = new Classroom()
@state.set { classCodeValid: false, segmentCheckValid: false }
.catch (error) ->
throw error
onInputBirthday: ->
{ birthdayYear, birthdayMonth, birthdayDay } = forms.formToObject(@$('form'))
birthday = new Date Date.UTC(birthdayYear, birthdayMonth - 1, birthdayDay)
@signupState.set { birthdayYear, birthdayMonth, birthdayDay, birthday }, { silent: true }
unless _.isNaN(birthday.getTime())
forms.clearFormAlerts(@$el)
onSubmitSegmentCheck: (e) ->
e.preventDefault()
if @signupState.get('path') is 'student'
@$('.class-code-input').attr('disabled', true)
@fetchClassByCode(@getClassCode())
.then (classroom) =>
return if @destroyed
if classroom
@signupState.set { classroom }
@trigger 'nav-forward'
else
@$('.class-code-input').attr('disabled', false)
@classroom = new Classroom()
@state.set { classCodeValid: false, segmentCheckValid: false }
.catch (error) ->
throw error
else if @signupState.get('path') is 'individual'
if _.isNaN(@signupState.get('birthday').getTime())
forms.clearFormAlerts(@$el)
forms.setErrorToProperty @$el, 'birthdayDay', 'Required'
else
age = (new Date().getTime() - @signupState.get('birthday').getTime()) / 365.4 / 24 / 60 / 60 / 1000
if age > 13
@trigger 'nav-forward'
else
@trigger 'nav-forward', 'coppa-deny'
fetchClassByCode: (classCode) ->
if not classCode
return Promise.resolve()
new Promise((resolve, reject) ->
new Classroom().fetchByCode(classCode, {
success: resolve
error: (classroom, jqxhr) ->
if jqxhr.status is 404
resolve()
else
reject(jqxhr.responseJSON)
})
)

View file

@ -0,0 +1,20 @@
CocoView = require 'views/core/CocoView'
template = require 'templates/core/create-account-modal/single-sign-on-already-exists-view'
forms = require 'core/forms'
User = require 'models/User'
module.exports = class SingleSignOnAlreadyExistsView extends CocoView
id: 'single-sign-on-already-exists-view'
template: template
events:
'click .back-button': 'onClickBackButton'
initialize: ({ @signupState }) ->
onClickBackButton: ->
@signupState.set {
ssoUsed: undefined
ssoAttrs: undefined
}
@trigger('nav-back')

View file

@ -0,0 +1,30 @@
CocoView = require 'views/core/CocoView'
BasicInfoView = require 'views/core/CreateAccountModal/BasicInfoView'
template = require 'templates/core/create-account-modal/single-sign-on-confirm-view'
forms = require 'core/forms'
User = require 'models/User'
module.exports = class SingleSignOnConfirmView extends BasicInfoView
id: 'single-sign-on-confirm-view'
template: template
events: _.extend {}, BasicInfoView.prototype.events, {
'click .back-button': 'onClickBackButton'
}
initialize: ({ @signupState } = {}) ->
super(arguments...)
onClickBackButton: ->
@signupState.set {
ssoUsed: undefined
ssoAttrs: undefined
}
@trigger 'nav-back'
formSchema: ->
type: 'object'
properties:
name: User.schema.properties.name
required: ['name']

View file

@ -0,0 +1 @@
module.exports = require 'views/core/CreateAccountModal/CreateAccountModal'

View file

@ -88,6 +88,8 @@ module.exports = class CoursesView extends RootView
if @classCodeQueryVar and not me.isAnonymous()
window.tracker?.trackEvent 'Students Join Class Link', category: 'Students', classCode: @classCodeQueryVar, ['Mixpanel']
@joinClass()
else if @classCodeQueryVar and me.isAnonymous()
@openModalView(new CreateAccountModal())
onClickLogInButton: ->
modal = new AuthModal()
@ -160,7 +162,7 @@ module.exports = class CoursesView extends RootView
if jqxhr.status is 422
@errorMessage = 'Please enter a code.'
else if jqxhr.status is 404
@errorMessage = 'Code not found.'
@errorMessage = $.t('signup.classroom_not_found')
else
@errorMessage = "#{jqxhr.responseText}"
@renderSelectors '#join-class-form'

View file

@ -46,6 +46,7 @@ module.exports = class SaveVersionModal extends ModalView
}
submitPatch: ->
@savingPatchError = false
forms.clearFormAlerts @$el
patch = new Patch()
patch.set 'delta', @model.getDelta()
@ -60,8 +61,10 @@ module.exports = class SaveVersionModal extends ModalView
return unless res
@enableModalInProgress(@$el)
res.error =>
res.error (jqxhr) =>
@disableModalInProgress(@$el)
@savingPatchError = jqxhr.responseJSON?.message or 'Unknown error.'
@renderSelectors '.save-error-area'
res.success =>
@hide()

View file

@ -16,7 +16,7 @@ const request = require('request');
const zpPageSize = 100;
getZPRepliedContacts((err, emailContactMap) => {
getZPContacts((err, emailContactMap) => {
if (err) {
console.log(err);
return;
@ -68,6 +68,7 @@ function createCloseLead(zpContact, done) {
}
function updateCloseLead(zpContact, existingLead, done) {
// console.log(`DEBUG: updateCloseLead ${existingLead.id} ${zpContact.email}`);
const putData = {
status: 'Contacted',
'custom.lastUpdated': new Date(),
@ -89,6 +90,9 @@ function updateCloseLead(zpContact, existingLead, done) {
title: zpContact.title,
emails: [{email: zpContact.email}]
};
if (zpContact.phone) {
postData.phones = [{phone: zpContact.phone}];
}
const options = {
uri: `https://${closeIoApiKey}:X@app.close.io/api/v1/contact/`,
body: JSON.stringify(postData)
@ -132,10 +136,9 @@ function createUpsertCloseLeadFn(zpContact) {
};
}
function getZPRepliedContactsPage(contacts, page, done) {
// console.log(`DEBUG: Fetching page ${page} ${zpPageSize}...`);
function getZPContactsPage(contacts, searchQuery, done) {
const options = {
url: `https://www.zenprospect.com/api/v1/contacts/search?codecombat_special_auth_token=${zpAuthToken}&page=${page}&per_page=${zpPageSize}&contact_email_replied=true`,
url: `https://www.zenprospect.com/api/v1/contacts/search?${searchQuery}`,
headers: {
'Accept': 'application/json'
}
@ -144,47 +147,69 @@ function getZPRepliedContactsPage(contacts, page, done) {
if (err) return done(err);
const data = JSON.parse(body);
for (let contact of data.contacts) {
if (contact.email_replied) {
contacts.push({
organization: contact.organization_name,
name: contact.name,
title: contact.title,
email: contact.email,
phone: contact.phone,
data: contact
});
}
contacts.push({
organization: contact.organization_name,
name: contact.name,
title: contact.title,
email: contact.email,
phone: contact.phone,
data: contact
});
}
return done(null, data.pipeline_total);
});
}
function getZPRepliedContacts(done) {
// Get first page to get total contact count for parallized page fetches
function createGetZPAutoResponderContactsPage(contacts, page) {
return (done) => {
// console.log(`DEBUG: Fetching autoresponder page ${page} ${zpPageSize}...`);
let searchQuery = `codecombat_special_auth_token=${zpAuthToken}&page=${page}&per_page=${zpPageSize}&contact_email_autoresponder=true`;
getZPContactsPage(contacts, searchQuery, done);
};
}
function createGetZPRepliedContactsPage(contacts, page) {
return (done) => {
// console.log(`DEBUG: Fetching email reply page ${page} ${zpPageSize}...`);
let searchQuery = `codecombat_special_auth_token=${zpAuthToken}&page=${page}&per_page=${zpPageSize}&contact_email_replied=true`;
getZPContactsPage(contacts, searchQuery, done);
};
}
function getZPContacts(done) {
// Get first page to get total contact count for future parallized page fetches
const contacts = [];
getZPRepliedContactsPage(contacts, 0, (err, total) => {
createGetZPAutoResponderContactsPage(contacts, 0)((err, autoResponderTotal) => {
if (err) return done(err);
const createGetZPLeadsPage = (leads, page) => {
return (done) => {
getZPRepliedContactsPage(leads, page, done);
};
}
const tasks = [];
for (let i = 1; (i - 1) * zpPageSize < total; i++) {
tasks.push(createGetZPLeadsPage(contacts, i));
}
async.series(tasks, (err, results) => {
createGetZPRepliedContactsPage(contacts, 0)((err, repliedTotal) => {
if (err) return done(err);
const emailContactMap = {};
for (const contact of contacts) {
if (!contact.organization || !contact.name || !contact.title || !contact.email) {
console.log(JSON.stringify(contact, null, 2));
return done(`DEBUG: missing data for zp contact:`);
}
if (!emailContactMap[contact.email]) emailContactMap[contact.email] = contact;
const tasks = [];
for (let i = 1; (i - 1) * zpPageSize < autoResponderTotal; i++) {
tasks.push(createGetZPAutoResponderContactsPage(contacts, i));
}
log(`${total} total ZP contacts, ${Object.keys(emailContactMap).length} with replies`);
return done(null, emailContactMap);
for (let i = 1; (i - 1) * zpPageSize < repliedTotal; i++) {
tasks.push(createGetZPRepliedContactsPage(contacts, i));
}
async.series(tasks, (err, results) => {
if (err) return done(err);
const emailContactMap = {};
for (const contact of contacts) {
if (!contact.organization || !contact.name || !contact.title || !contact.email) {
console.log(JSON.stringify(contact, null, 2));
return done(`DEBUG: missing data for zp contact:`);
}
if (!emailContactMap[contact.email]) {
emailContactMap[contact.email] = contact;
}
// else {
// console.log(`DEBUG: already have contact ${contact.email}`);
// }
}
log(`(${autoResponderTotal + repliedTotal}) ${autoResponderTotal} autoresponder ZP contacts ${repliedTotal} ZP contacts ${Object.keys(emailContactMap).length} contacts mapped`);
return done(null, emailContactMap);
});
});
});
}

View file

@ -103,7 +103,7 @@ UserHandler = class UserHandler extends Handler
return callback(res: 'Facebook user login error.', code: 500) if err
return callback(null, req, otherUser)
)
r = {message: 'is already used by another account', property: 'email'}
r = {message: 'is already used by another account', property: 'email', code: 409}
return callback({res: r, code: 409}) if otherUser
user.set('email', req.body.email)
callback(null, req, user)
@ -118,7 +118,7 @@ UserHandler = class UserHandler extends Handler
User.findOne({nameLower: nameLower, anonymous: false}).exec (err, otherUser) ->
log.error "Database error setting user name: #{err}" if err
return callback(res: 'Database error.', code: 500) if err
r = {message: 'is already used by another account', property: 'name'}
r = {message: 'is already used by another account', property: 'name', code: 409}
log.info 'Another user exists' if otherUser
return callback({res: r, code: 409}) if otherUser
user.set('name', req.body.name)

View file

@ -98,6 +98,7 @@ module.exports =
processLicenseRequest: (teacherEmail, userID, leadID, licensesRequested, amount, done) ->
# Update lead with licenses requested
licensesRequested = parseInt(licensesRequested)
putData = 'custom.licensesRequested': licensesRequested
options =
uri: "https://#{apiKey}:X@app.close.io/api/v1/lead/#{leadID}/"
@ -135,7 +136,7 @@ module.exports =
date_won: dateWon.toISOString().substring(0, 10)
lead_id: leadID
status: 'Active'
value: parseInt(licensesRequested) * amount
value: licensesRequested * amount
value_period: "annual"
options =
uri: "https://#{apiKey}:X@app.close.io/api/v1/opportunity/"

View file

@ -0,0 +1,11 @@
request = require 'request'
Promise = require 'bluebird'
module.exports.fetchMe = (facebookAccessToken) ->
return new Promise (resolve, reject) ->
url = "https://graph.facebook.com/me?access_token=#{facebookAccessToken}"
request.get url, {json: true}, (err, res) ->
if err
reject(err)
else
resolve(res.body)

11
server/lib/gplus.coffee Normal file
View file

@ -0,0 +1,11 @@
request = require 'request'
Promise = require 'bluebird'
module.exports.fetchMe = (gplusAccessToken) ->
return new Promise (resolve, reject) ->
url = "https://www.googleapis.com/oauth2/v2/userinfo?access_token=#{gplusAccessToken}"
request.get url, {json: true}, (err, res) ->
if err
reject(err)
else
resolve(res.body)

View file

@ -197,12 +197,21 @@ module.exports =
name: wrap (req, res) ->
if not req.params.name
throw new errors.UnprocessableEntity 'No name provided.'
originalName = req.params.name
givenName = req.params.name
User.unconflictNameAsync = Promise.promisify(User.unconflictName)
name = yield User.unconflictNameAsync originalName
response = name: name
if originalName is name
res.send 200, response
else
throw new errors.Conflict('Name is taken', response)
suggestedName = yield User.unconflictNameAsync givenName
response = {
givenName
suggestedName
conflicts: givenName isnt suggestedName
}
res.send 200, response
email: wrap (req, res) ->
{ email } = req.params
if not email
throw new errors.UnprocessableEntity 'No email provided.'
user = yield User.findByEmail(email)
res.send 200, { exists: user? }

View file

@ -0,0 +1,18 @@
sendwithus = require '../sendwithus'
utils = require '../lib/utils'
errors = require '../commons/errors'
wrap = require 'co-express'
database = require '../commons/database'
parse = require '../commons/parse'
module.exports =
sendParentSignupInstructions: wrap (req, res, next) ->
context =
email_id: sendwithus.templates.coppa_deny_parent_signup
recipient:
address: req.body.parentEmail
sendwithus.api.send context, (err, result) ->
if err
return next(new errors.InternalServerError("Error sending email. Check that it's valid and try again."))
else
res.status(200).send()

View file

@ -4,6 +4,7 @@ module.exports =
classrooms: require './classrooms'
campaigns: require './campaigns'
codelogs: require './codelogs'
contact: require './contact'
courseInstances: require './course-instances'
courses: require './courses'
files: require './files'

View file

@ -11,6 +11,8 @@ mongoose = require 'mongoose'
sendwithus = require '../sendwithus'
User = require '../models/User'
Classroom = require '../models/Classroom'
facebook = require '../lib/facebook'
gplus = require '../lib/gplus'
module.exports =
fetchByGPlusID: wrap (req, res, next) ->
@ -18,12 +20,12 @@ module.exports =
gpAT = req.query.gplusAccessToken
return next() unless gpID and gpAT
googleResponse = yield gplus.fetchMe(gpAT)
idsMatch = gpID is googleResponse.id
throw new errors.UnprocessableEntity('Invalid G+ Access Token.') unless idsMatch
dbq = User.find()
dbq.select(parse.getProjectFromReq(req))
url = "https://www.googleapis.com/oauth2/v2/userinfo?access_token=#{gpAT}"
[googleRes, body] = yield request.getAsync(url, {json: true})
idsMatch = gpID is body.id
throw new errors.UnprocessableEntity('Invalid G+ Access Token.') unless idsMatch
user = yield User.findOne({gplusID: gpID})
throw new errors.NotFound('No user with that G+ ID') unless user
res.status(200).send(user.toObject({req: req}))
@ -33,12 +35,12 @@ module.exports =
fbAT = req.query.facebookAccessToken
return next() unless fbID and fbAT
facebookResponse = yield facebook.fetchMe(fbAT)
idsMatch = fbID is facebookResponse.id
throw new errors.UnprocessableEntity('Invalid Facebook Access Token.') unless idsMatch
dbq = User.find()
dbq.select(parse.getProjectFromReq(req))
url = "https://graph.facebook.com/me?access_token=#{fbAT}"
[facebookRes, body] = yield request.getAsync(url, {json: true})
idsMatch = fbID is body.id
throw new errors.UnprocessableEntity('Invalid Facebook Access Token.') unless idsMatch
user = yield User.findOne({facebookID: fbID})
throw new errors.NotFound('No user with that Facebook ID') unless user
res.status(200).send(user.toObject({req: req}))
@ -117,3 +119,80 @@ module.exports =
if country = user.geo?.country
user.geo.countryName = countryList.getName(country)
res.status(200).send(users)
signupWithPassword: wrap (req, res) ->
unless req.user.isAnonymous()
throw new errors.Forbidden('You are already signed in.')
{ password, email } = req.body
unless _.all([password, email])
throw new errors.UnprocessableEntity('Requires password and email')
if yield User.findByEmail(email)
throw new errors.Conflict('Email already taken')
req.user.set({ password, email, anonymous: false })
try
yield req.user.save()
catch e
if e.code is 11000 # Duplicate key error
throw new errors.Conflict('Email already taken')
else
throw e
req.user.sendWelcomeEmail()
res.status(200).send(req.user.toObject({req: req}))
signupWithFacebook: wrap (req, res) ->
unless req.user.isAnonymous()
throw new errors.Forbidden('You are already signed in.')
{ facebookID, facebookAccessToken, email } = req.body
unless _.all([facebookID, facebookAccessToken, email])
throw new errors.UnprocessableEntity('Requires facebookID, facebookAccessToken and email')
facebookResponse = yield facebook.fetchMe(facebookAccessToken)
emailsMatch = email is facebookResponse.email
idsMatch = facebookID is facebookResponse.id
unless emailsMatch and idsMatch
throw new errors.UnprocessableEntity('Invalid facebookAccessToken')
req.user.set({ facebookID, email, anonymous: false })
try
yield req.user.save()
catch e
if e.code is 11000 # Duplicate key error
throw new errors.Conflict('Email already taken')
else
throw e
req.user.sendWelcomeEmail()
res.status(200).send(req.user.toObject({req: req}))
signupWithGPlus: wrap (req, res) ->
unless req.user.isAnonymous()
throw new errors.Forbidden('You are already signed in.')
{ gplusID, gplusAccessToken, email } = req.body
unless _.all([gplusID, gplusAccessToken, email])
throw new errors.UnprocessableEntity('Requires gplusID, gplusAccessToken and email')
gplusResponse = yield gplus.fetchMe(gplusAccessToken)
emailsMatch = email is gplusResponse.email
idsMatch = gplusID is gplusResponse.id
unless emailsMatch and idsMatch
throw new errors.UnprocessableEntity('Invalid gplusAccessToken')
req.user.set({ gplusID, email, anonymous: false })
try
yield req.user.save()
catch e
if e.code is 11000 # Duplicate key error
throw new errors.Conflict('Email already taken')
else
throw e
req.user.sendWelcomeEmail()
res.status(200).send(req.user.toObject({req: req}))

View file

@ -31,6 +31,6 @@ AnalyticsLogEventSchema.statics.logEvent = (user, event, properties={}) ->
unless config.proxy
analyticsMongoose = mongoose.createConnection()
analyticsMongoose.open "mongodb://#{config.mongo.analytics_host}:#{config.mongo.analytics_port}/#{config.mongo.analytics_db}", (error) ->
console.log "Couldnt connect to analytics", error
log.warn "Couldnt connect to analytics", error
module.exports = AnalyticsLogEvent = analyticsMongoose.model('analytics.log.event', AnalyticsLogEventSchema, config.mongo.analytics_collection)

View file

@ -118,6 +118,10 @@ UserSchema.statics.search = (term, done) ->
query = $or: [{nameLower: term}, {emailLower: term}]
return User.findOne(query).exec(done)
UserSchema.statics.findByEmail = (email, done=_.noop) ->
emailLower = email.toLowerCase()
User.findOne({emailLower: emailLower}).exec(done)
emailNameMap =
generalNews: 'announcement'
adventurerNews: 'tester'
@ -262,14 +266,7 @@ UserSchema.statics.unconflictName = unconflictName = (name, done) ->
suffix = _.random(0, 9) + ''
unconflictName name + suffix, done
UserSchema.methods.register = (done) ->
@set('anonymous', false)
if (name = @get 'name')? and name isnt ''
unconflictName name, (err, uniqueName) =>
return done err if err
@set 'name', uniqueName
done()
else done()
UserSchema.methods.sendWelcomeEmail = ->
{ welcome_email_student, welcome_email_user } = sendwithus.templates
timestamp = (new Date).getTime()
data =
@ -282,7 +279,6 @@ UserSchema.methods.register = (done) ->
verify_link: "http://codecombat.com/user/#{@_id}/verify/#{@verificationCode(timestamp)}"
sendwithus.api.send data, (err, result) ->
log.error "sendwithus post-save error: #{err}, result: #{result}" if err
@saveActiveUser 'register'
UserSchema.methods.hasSubscription = ->
return false unless stripeObject = @get('stripe')
@ -361,10 +357,7 @@ UserSchema.pre('save', (next) ->
if @get('password')
@set('passwordHash', User.hashPassword(pwd))
@set('password', undefined)
if @get('email') and @get('anonymous') # a user registers
@register next
else
next()
next()
)
UserSchema.post 'save', (doc) ->

View file

@ -8,12 +8,15 @@ module.exports.setup = (app) ->
app.post('/auth/login-gplus', mw.auth.loginByGPlus, mw.auth.afterLogin)
app.post('/auth/logout', mw.auth.logout)
app.get('/auth/name/?(:name)?', mw.auth.name)
app.get('/auth/email/?(:email)?', mw.auth.email)
app.post('/auth/reset', mw.auth.reset)
app.post('/auth/spy', mw.auth.spy)
app.post('/auth/stop-spying', mw.auth.stopSpying)
app.get('/auth/unsubscribe', mw.auth.unsubscribe)
app.get('/auth/whoami', mw.auth.whoAmI)
app.post('/contact/send-parent-signup-instructions', mw.contact.sendParentSignupInstructions)
app.delete('/db/*', mw.auth.checkHasUser())
app.patch('/db/*', mw.auth.checkHasUser())
app.post('/db/*', mw.auth.checkHasUser())
@ -96,6 +99,9 @@ module.exports.setup = (app) ->
app.get('/db/level/:handle/session', mw.auth.checkHasUser(), mw.levels.upsertSession)
app.get('/db/user/-/students', mw.auth.checkHasPermission(['admin']), mw.users.getStudents)
app.get('/db/user/-/teachers', mw.auth.checkHasPermission(['admin']), mw.users.getTeachers)
app.post('/db/user/:handle/signup-with-facebook', mw.users.signupWithFacebook)
app.post('/db/user/:handle/signup-with-gplus', mw.users.signupWithGPlus)
app.post('/db/user/:handle/signup-with-password', mw.users.signupWithPassword)
app.get('/db/prepaid', mw.auth.checkLoggedIn(), mw.prepaids.fetchByCreator)
app.post('/db/prepaid', mw.auth.checkHasPermission(['admin']), mw.prepaids.post)

View file

@ -17,6 +17,7 @@ if swuAPIKey
module.exports.templates =
parent_subscribe_email: 'tem_2APERafogvwKhmcnouigud'
coppa_deny_parent_signup: 'tem_d5fCpXS8V7jgff2sYKCinX'
share_progress_email: 'tem_VHE3ihhGmVa3727qds9zY8'
welcome_email_user: 'tem_z7Xvj3mtWYk6ec6aW7RwFk'
welcome_email_student: 'tem_4WYPZNLzs5wawMF9qUJXUH'

View file

@ -73,6 +73,8 @@ setupErrorMiddleware = (app) ->
res.status(err.status ? 500).send(error: "Something went wrong!")
message = "Express error: #{req.method} #{req.path}: #{err.message}"
log.error "#{message}, stack: #{err.stack}"
if global.testing
console.log "#{message}, stack: #{err.stack}"
slack.sendSlackMessage(message, ['ops'], {papertrail: true})
else
next(err)

View file

@ -231,18 +231,20 @@ describe 'GET /auth/name', ->
expect(res.statusCode).toBe 422
done()
it 'returns the name given if there is no conflict', utils.wrap (done) ->
[res, body] = yield request.getAsync {url: getURL(url + '/Gandalf'), json: {}}
it 'returns an object with properties conflicts, givenName and suggestedName', utils.wrap (done) ->
[res, body] = yield request.getAsync {url: getURL(url + '/Gandalf'), json: true}
expect(res.statusCode).toBe 200
expect(res.body.name).toBe 'Gandalf'
done()
expect(res.body.givenName).toBe 'Gandalf'
expect(res.body.conflicts).toBe false
expect(res.body.suggestedName).toBe 'Gandalf'
it 'returns a new name in case of conflict', utils.wrap (done) ->
yield utils.initUser({name: 'joe'})
[res, body] = yield request.getAsync {url: getURL(url + '/joe'), json: {}}
expect(res.statusCode).toBe 409
expect(res.body.name).not.toBe 'joe'
expect(/joe[0-9]/.test(res.body.name)).toBe(true)
expect(res.statusCode).toBe 200
expect(res.body.suggestedName).not.toBe 'joe'
expect(res.body.conflicts).toBe true
expect(/joe[0-9]/.test(res.body.suggestedName)).toBe(true)
done()

View file

@ -5,6 +5,10 @@ User = require '../../../server/models/User'
Classroom = require '../../../server/models/Classroom'
Prepaid = require '../../../server/models/Prepaid'
request = require '../request'
facebook = require '../../../server/lib/facebook'
gplus = require '../../../server/lib/gplus'
sendwithus = require '../../../server/sendwithus'
Promise = require 'bluebird'
describe 'POST /db/user', ->
@ -177,27 +181,6 @@ ghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghl
sam.set 'name', samsName
done()
it 'should silently rename an anonymous user if their name conflicts upon signup', (done) ->
request.post getURL('/auth/logout'), ->
request.get getURL('/auth/whoami'), ->
json = { name: 'admin' }
request.post { url: getURL('/db/user'), json }, (err, response) ->
expect(response.statusCode).toBe(200)
request.get getURL('/auth/whoami'), (err, response) ->
expect(err).toBeNull()
guy = JSON.parse(response.body)
expect(guy.anonymous).toBeTruthy()
expect(guy.name).toEqual 'admin'
guy.email = 'blub@blub' # Email means registration
req = request.post {url: getURL('/db/user'), json: guy}, (err, response) ->
expect(err).toBeNull()
finalGuy = response.body
expect(finalGuy.anonymous).toBeFalsy()
expect(finalGuy.name).not.toEqual guy.name
expect(finalGuy.name.length).toBe guy.name.length + 1
done()
it 'should be able to unset a slug by setting an empty name', (done) ->
loginSam (sam) ->
samsName = sam.get 'name'
@ -690,3 +673,206 @@ describe 'Statistics', ->
expect(err).toBeNull()
done()
describe 'POST /db/user/:handle/signup-with-password', ->
beforeEach utils.wrap (done) ->
yield utils.clearModels([User])
yield new Promise((resolve) -> setTimeout(resolve, 10))
done()
it 'signs up the user with the password and sends welcome emails', utils.wrap (done) ->
spyOn(sendwithus.api, 'send')
user = yield utils.becomeAnonymous()
url = getURL("/db/user/#{user.id}/signup-with-password")
email = 'some@email.com'
json = { email, password: '12345' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(200)
updatedUser = yield User.findById(user.id)
expect(updatedUser.get('email')).toBe(email)
expect(updatedUser.get('passwordHash')).toBeDefined()
expect(sendwithus.api.send).toHaveBeenCalled()
done()
it 'returns 409 if there is already a user with the given email', utils.wrap (done) ->
email = 'some@email.com'
initialUser = yield utils.initUser({email})
expect(initialUser.get('emailLower')).toBeDefined()
user = yield utils.becomeAnonymous()
url = getURL("/db/user/#{user.id}/signup-with-password")
json = { email, password: '12345' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(409)
done()
describe 'POST /db/user/:handle/signup-with-facebook', ->
facebookID = '12345'
facebookEmail = 'some@email.com'
validFacebookResponse = new Promise((resolve) -> resolve({
id: facebookID,
email: facebookEmail,
first_name: 'Some',
gender: 'male',
last_name: 'Person',
link: 'https://www.facebook.com/app_scoped_user_id/12345/',
locale: 'en_US',
name: 'Some Person',
timezone: -7,
updated_time: '2015-12-08T17:10:39+0000',
verified: true
}))
invalidFacebookResponse = new Promise((resolve) -> resolve({
error: {
message: 'Invalid OAuth access token.',
type: 'OAuthException',
code: 190,
fbtrace_id: 'EC4dEdeKHBH'
}
}))
beforeEach utils.wrap (done) ->
yield utils.clearModels([User])
yield new Promise((resolve) -> setTimeout(resolve, 10))
done()
it 'signs up the user with the facebookID and sends welcome emails', utils.wrap (done) ->
spyOn(facebook, 'fetchMe').and.returnValue(validFacebookResponse)
spyOn(sendwithus.api, 'send')
user = yield utils.becomeAnonymous()
url = getURL("/db/user/#{user.id}/signup-with-facebook")
json = { email: facebookEmail, facebookID, facebookAccessToken: '...' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(200)
updatedUser = yield User.findById(user.id)
expect(updatedUser.get('email')).toBe(facebookEmail)
expect(updatedUser.get('facebookID')).toBe(facebookID)
expect(sendwithus.api.send).toHaveBeenCalled()
done()
it 'returns 422 if facebook does not recognize the access token', utils.wrap (done) ->
spyOn(facebook, 'fetchMe').and.returnValue(invalidFacebookResponse)
user = yield utils.becomeAnonymous()
url = getURL("/db/user/#{user.id}/signup-with-facebook")
json = { email: facebookEmail, facebookID, facebookAccessToken: '...' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(422)
done()
it 'returns 422 if the email or id do not match', utils.wrap (done) ->
spyOn(facebook, 'fetchMe').and.returnValue(validFacebookResponse)
user = yield utils.becomeAnonymous()
url = getURL("/db/user/#{user.id}/signup-with-facebook")
json = { email: 'some-other@email.com', facebookID, facebookAccessToken: '...' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(422)
json = { email: facebookEmail, facebookID: '54321', facebookAccessToken: '...' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(422)
done()
it 'returns 409 if there is already a user with the given email', utils.wrap (done) ->
initialUser = yield utils.initUser({email: facebookEmail})
expect(initialUser.get('emailLower')).toBeDefined()
spyOn(facebook, 'fetchMe').and.returnValue(validFacebookResponse)
user = yield utils.becomeAnonymous()
url = getURL("/db/user/#{user.id}/signup-with-facebook")
json = { email: facebookEmail, facebookID, facebookAccessToken: '...' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(409)
done()
describe 'POST /db/user/:handle/signup-with-gplus', ->
gplusID = '12345'
gplusEmail = 'some@email.com'
validGPlusResponse = new Promise((resolve) -> resolve({
id: gplusID
email: gplusEmail,
verified_email: true,
name: 'Some Person',
given_name: 'Some',
family_name: 'Person',
link: 'https://plus.google.com/12345',
picture: 'https://lh6.googleusercontent.com/...',
gender: 'male',
locale: 'en'
}))
invalidGPlusResponse = new Promise((resolve) -> resolve({
"error": {
"errors": [
{
"domain": "global",
"reason": "authError",
"message": "Invalid Credentials",
"locationType": "header",
"location": "Authorization"
}
],
"code": 401,
"message": "Invalid Credentials"
}
}))
beforeEach utils.wrap (done) ->
yield utils.clearModels([User])
yield new Promise((resolve) -> setTimeout(resolve, 10))
done()
it 'signs up the user with the gplusID and sends welcome emails', utils.wrap (done) ->
spyOn(gplus, 'fetchMe').and.returnValue(validGPlusResponse)
spyOn(sendwithus.api, 'send')
user = yield utils.becomeAnonymous()
url = getURL("/db/user/#{user.id}/signup-with-gplus")
json = { email: gplusEmail, gplusID, gplusAccessToken: '...' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(200)
updatedUser = yield User.findById(user.id)
expect(updatedUser.get('email')).toBe(gplusEmail)
expect(updatedUser.get('gplusID')).toBe(gplusID)
expect(sendwithus.api.send).toHaveBeenCalled()
done()
it 'returns 422 if gplus does not recognize the access token', utils.wrap (done) ->
spyOn(gplus, 'fetchMe').and.returnValue(invalidGPlusResponse)
user = yield utils.becomeAnonymous()
url = getURL("/db/user/#{user.id}/signup-with-gplus")
json = { email: gplusEmail, gplusID, gplusAccessToken: '...' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(422)
done()
it 'returns 422 if the email or id do not match', utils.wrap (done) ->
spyOn(gplus, 'fetchMe').and.returnValue(validGPlusResponse)
user = yield utils.becomeAnonymous()
url = getURL("/db/user/#{user.id}/signup-with-gplus")
json = { email: 'some-other@email.com', gplusID, gplusAccessToken: '...' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(422)
json = { email: gplusEmail, gplusID: '54321', gplusAccessToken: '...' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(422)
done()
it 'returns 409 if there is already a user with the given email', utils.wrap (done) ->
yield utils.initUser({email: gplusEmail})
spyOn(gplus, 'fetchMe').and.returnValue(validGPlusResponse)
user = yield utils.becomeAnonymous()
url = getURL("/db/user/#{user.id}/signup-with-gplus")
json = { email: gplusEmail, gplusID, gplusAccessToken: '...' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(409)
done()

View file

@ -74,7 +74,8 @@ module.exports = mw =
becomeAnonymous: Promise.promisify (done) ->
request.post mw.getURL('/auth/logout'), ->
request.get mw.getURL('/auth/whoami'), done
request.get mw.getURL('/auth/whoami'), {json: true}, (err, res) ->
User.findById(res.body._id).exec(done)
logout: Promise.promisify (done) ->
request.post mw.getURL('/auth/logout'), done

View file

@ -1,240 +1,414 @@
CreateAccountModal = require 'views/core/CreateAccountModal'
COPPADenyModal = require 'views/core/COPPADenyModal'
Classroom = require 'models/Classroom'
#COPPADenyModal = require 'views/core/COPPADenyModal'
forms = require 'core/forms'
factories = require 'test/app/factories'
describe 'CreateAccountModal', ->
# TODO: Figure out why these tests break Travis. Suspect it has to do with the
# asynchronous, Promise system. On the browser, these work, but in Travis, they
# sometimes fail, so it's some sort of race condition.
responses = {
signupSuccess: { status: 200, responseText: JSON.stringify({ email: 'some@email.com' })}
}
xdescribe 'CreateAccountModal', ->
modal = null
initModal = (options) -> (done) ->
application.facebookHandler.fakeAPI()
application.gplusHandler.fakeAPI()
modal = new CreateAccountModal(options)
modal.render()
modal.render = _.noop
jasmine.demoModal(modal)
_.defer done
# initModal = (options) -> ->
# application.facebookHandler.fakeAPI()
# application.gplusHandler.fakeAPI()
# modal = new CreateAccountModal(options)
# jasmine.demoModal(modal)
afterEach ->
modal.stopListening()
describe 'click SIGN IN button', ->
it 'switches to AuthModal', ->
modal = new CreateAccountModal()
modal.render()
jasmine.demoModal(modal)
spyOn(modal, 'openModalView')
modal.$('.login-link').click()
expect(modal.openModalView).toHaveBeenCalled()
describe 'constructed with showRequiredError is true', ->
beforeEach initModal({showRequiredError: true})
it 'shows a modal explaining to login first', ->
expect(modal.$('#required-error-alert').length).toBe(1)
describe 'constructed with showSignupRationale is true', ->
beforeEach initModal({showSignupRationale: true})
it 'shows a modal explaining signup rationale', ->
expect(modal.$('#signup-rationale-alert').length).toBe(1)
describe 'clicking the save button', ->
beforeEach initModal()
it 'fails if nothing is in the form, showing errors for email, birthday, and password', ->
modal.$('form').each (i, el) -> el.reset()
modal.$('form').submit()
expect(jasmine.Ajax.requests.all().length).toBe(0)
expect(modal.$('.has-error').length).toBe(3)
it 'fails if email is missing', ->
modal.$('form').each (i, el) -> el.reset()
forms.objectToForm(modal.$el, { name: 'Name', password: 'xyzzy', birthdayDay: 24, birthdayMonth: 7, birthdayYear: 1988 })
modal.$('form').submit()
expect(jasmine.Ajax.requests.all().length).toBe(0)
expect(modal.$('.has-error').length).toBeTruthy()
it 'fails if birthday is missing', ->
modal.$('form').each (i, el) -> el.reset()
forms.objectToForm(modal.$el, { email: 'some@email.com', password: 'xyzzy' })
modal.$('form').submit()
expect(jasmine.Ajax.requests.all().length).toBe(0)
expect(modal.$('.has-error').length).toBe(1)
it 'fails if user is too young', ->
modal.$('form').each (i, el) -> el.reset()
forms.objectToForm(modal.$el, { email: 'some@email.com', password: 'xyzzy', birthdayDay: 24, birthdayMonth: 7, birthdayYear: (new Date().getFullYear() - 10) })
modalOpened = false
spyOn(modal, 'openModalView').and.callFake (modal) ->
modalOpened = true
expect(modal instanceof COPPADenyModal).toBe(true)
modal.$('form').submit()
expect(jasmine.Ajax.requests.all().length).toBe(0)
expect(modalOpened).toBeTruthy()
it 'signs up if only email, birthday, and password is provided', ->
modal.$('form').each (i, el) -> el.reset()
forms.objectToForm(modal.$el, { email: 'some@email.com', password: 'xyzzy', birthdayDay: 24, birthdayMonth: 7, birthdayYear: 1988 })
modal.$('form').submit()
requests = jasmine.Ajax.requests.all()
expect(requests.length).toBe(1)
expect(modal.$el.has('.has-warning').length).toBeFalsy()
expect(modal.$('#signup-button').is(':disabled')).toBe(true)
describe 'and a class code is entered', ->
describe 'ChooseAccountTypeView', ->
beforeEach ->
modal = new CreateAccountModal()
modal.render()
jasmine.demoModal(modal)
describe 'click sign up as TEACHER button', ->
beforeEach ->
modal.$('form').each (i, el) -> el.reset()
forms.objectToForm(modal.$el, { email: 'some@email.com', password: 'xyzzy', classCode: 'qwerty' })
modal.$('form').submit()
expect(jasmine.Ajax.requests.all().length).toBe(1)
spyOn application.router, 'navigate'
modal.$('.teacher-path-button').click()
it 'checks for Classroom existence if a class code was entered', ->
it 'navigates the user to /teachers/signup', ->
expect(application.router.navigate).toHaveBeenCalled()
args = application.router.navigate.calls.argsFor(0)
expect(args[0]).toBe('/teachers/signup')
describe 'click sign up as STUDENT button', ->
beforeEach ->
modal.$('.student-path-button').click()
it 'switches to SegmentCheckView and sets "path" to "student"', ->
expect(modal.signupState.get('path')).toBe('student')
expect(modal.signupState.get('screen')).toBe('segment-check')
describe 'click sign up as INDIVIDUAL button', ->
beforeEach ->
modal.$('.individual-path-button').click()
it 'switches to SegmentCheckView and sets "path" to "individual"', ->
expect(modal.signupState.get('path')).toBe('individual')
expect(modal.signupState.get('screen')).toBe('segment-check')
describe 'SegmentCheckView', ->
segmentCheckView = null
describe 'INDIVIDUAL path', ->
beforeEach ->
modal = new CreateAccountModal()
modal.render()
jasmine.demoModal(modal)
request = jasmine.Ajax.requests.mostRecent()
expect(request.url).toBe('/db/classroom?code=qwerty')
modal.$('.individual-path-button').click()
segmentCheckView = modal.subviews.segment_check_view
it 'has not hidden the close-modal button', ->
expect(modal.$('#close-modal').css('display')).not.toBe('none')
it 'has a birthdate form', ->
expect(modal.$('.birthday-form-group').length).toBe(1)
describe 'the Classroom exists', ->
it 'continues with signup', ->
describe 'STUDENT path', ->
beforeEach ->
modal = new CreateAccountModal()
modal.render()
jasmine.demoModal(modal)
modal.$('.student-path-button').click()
segmentCheckView = modal.subviews.segment_check_view
spyOn(segmentCheckView, 'checkClassCodeDebounced')
it 'has a classCode input', ->
expect(modal.$('.class-code-input').length).toBe(1)
it 'checks the class code when the input changes', ->
modal.$('.class-code-input').val('test').trigger('input')
expect(segmentCheckView.checkClassCodeDebounced).toHaveBeenCalled()
describe 'fetchClassByCode()', ->
it 'is memoized', ->
promise1 = segmentCheckView.fetchClassByCode('testA')
promise2 = segmentCheckView.fetchClassByCode('testA')
promise3 = segmentCheckView.fetchClassByCode('testB')
expect(promise1).toBe(promise2)
expect(promise1).not.toBe(promise3)
describe 'checkClassCode()', ->
it 'shows a success message if the classCode is found', ->
request = jasmine.Ajax.requests.mostRecent()
request.respondWith({status: 200, responseText: JSON.stringify({})})
expect(request).toBeUndefined()
modal.$('.class-code-input').val('test').trigger('input')
segmentCheckView.checkClassCode()
request = jasmine.Ajax.requests.mostRecent()
expect(request.url).toBe('/db/user')
expect(request.method).toBe('POST')
expect(request).toBeDefined()
request.respondWith({
status: 200
responseText: JSON.stringify({
data: factories.makeClassroom({name: 'Some Classroom'}).toJSON()
owner: factories.makeUser({name: 'Some Teacher'}).toJSON()
})
})
describe 'the Classroom does not exist', ->
it 'shows an error and clears the field', ->
describe 'on submit with class code', ->
classCodeRequest = null
beforeEach ->
request = jasmine.Ajax.requests.mostRecent()
request.respondWith({status: 404, responseText: JSON.stringify({})})
expect(jasmine.Ajax.requests.all().length).toBe(1)
expect(modal.$el.has('.has-error').length).toBeTruthy()
expect(modal.$('#class-code-input').val()).toBe('')
expect(request).toBeUndefined()
modal.$('.class-code-input').val('test').trigger('input')
modal.$('form.segment-check').submit()
classCodeRequest = jasmine.Ajax.requests.mostRecent()
expect(classCodeRequest).toBeDefined()
describe 'when the classroom IS found', ->
beforeEach (done) ->
classCodeRequest.respondWith({
status: 200
responseText: JSON.stringify({
data: factories.makeClassroom({name: 'Some Classroom'}).toJSON()
owner: factories.makeUser({name: 'Some Teacher'}).toJSON()
})
})
_.defer done
describe 'clicking the gplus button', ->
it 'navigates to the BasicInfoView', ->
expect(modal.signupState.get('screen')).toBe('basic-info')
signupButton = null
describe 'when the classroom IS NOT found', ->
beforeEach (done) ->
classCodeRequest.respondWith({
status: 404
responseText: '{}'
})
segmentCheckView.once 'special-render', done
beforeEach initModal()
it 'shows an error', ->
expect(modal.$('[data-i18n="signup.classroom_not_found"]').length).toBe(1)
describe 'CoppaDenyView', ->
coppaDenyView = null
beforeEach ->
forms.objectToForm(modal.$el, { birthdayDay: 24, birthdayMonth: 7, birthdayYear: 1988 })
signupButton = modal.$('#gplus-signup-btn')
expect(signupButton.attr('disabled')).toBeFalsy()
signupButton.click()
modal = new CreateAccountModal()
modal.signupState.set({
path: 'individual'
screen: 'coppa-deny'
})
modal.render()
jasmine.demoModal(modal)
coppaDenyView = modal.subviews.coppa_deny_view
it 'checks to see if the user already exists in our system', ->
requests = jasmine.Ajax.requests.all()
expect(requests.length).toBe(1)
expect(signupButton.attr('disabled')).toBeTruthy()
it 'shows an input for a parent\'s email address to sign up their child', ->
expect(modal.$('#parent-email-input').length).toBe(1)
describe 'and finding the given person is already a user', ->
beforeEach ->
expect(modal.$('#gplus-account-exists-row').hasClass('hide')).toBe(true)
request = jasmine.Ajax.requests.mostRecent()
request.respondWith({status: 200, responseText: JSON.stringify({_id: 'existinguser'})})
describe 'BasicInfoView', ->
it 'shows a message saying you are connected with Google+, with a button for logging in', ->
expect(modal.$('#gplus-account-exists-row').hasClass('hide')).toBe(false)
loginBtn = modal.$('#gplus-login-btn')
expect(loginBtn.attr('disabled')).toBeFalsy()
loginBtn.click()
expect(loginBtn.attr('disabled')).toBeTruthy()
request = jasmine.Ajax.requests.mostRecent()
expect(request.method).toBe('POST')
expect(request.params).toBe('gplusID=abcd&gplusAccessToken=1234')
expect(request.url).toBe('/auth/login-gplus')
describe 'and the user finishes signup anyway with new info', ->
beforeEach ->
forms.objectToForm(modal.$el, { email: 'some@email.com', birthdayDay: 24, birthdayMonth: 7, birthdayYear: 1988 })
modal.$('form').submit()
it 'upserts the values to the new user', ->
request = jasmine.Ajax.requests.mostRecent()
expect(request.method).toBe('PUT')
expect(request.url).toBe('/db/user?gplusID=abcd&gplusAccessToken=1234')
describe 'and finding the given person is not yet a user', ->
beforeEach ->
expect(modal.$('#gplus-logged-in-row').hasClass('hide')).toBe(true)
request = jasmine.Ajax.requests.mostRecent()
request.respondWith({status: 404})
it 'shows a message saying you are connected with Google+', ->
expect(modal.$('#gplus-logged-in-row').hasClass('hide')).toBe(false)
describe 'and the user finishes signup', ->
beforeEach ->
modal.$('form').submit()
it 'creates the user with the gplus attributes', ->
request = jasmine.Ajax.requests.mostRecent()
expect(request.method).toBe('PUT')
expect(request.url).toBe('/db/user?gplusID=abcd&gplusAccessToken=1234')
expect(_.string.startsWith(request.url, '/db/user')).toBe(true)
expect(modal.$('#signup-button').is(':disabled')).toBe(true)
describe 'clicking the facebook button', ->
signupButton = null
beforeEach initModal()
basicInfoView = null
beforeEach ->
forms.objectToForm(modal.$el, { birthdayDay: 24, birthdayMonth: 7, birthdayYear: 1988 })
signupButton = modal.$('#facebook-signup-btn')
expect(signupButton.attr('disabled')).toBeFalsy()
signupButton.click()
modal = new CreateAccountModal()
modal.signupState.set({
path: 'individual'
screen: 'basic-info'
})
modal.render()
jasmine.demoModal(modal)
basicInfoView = modal.subviews.basic_info_view
it 'checks to see if the user already exists in our system', ->
requests = jasmine.Ajax.requests.all()
expect(requests.length).toBe(1)
expect(signupButton.attr('disabled')).toBeTruthy()
it 'checks for name conflicts when the name input changes', ->
spyOn(basicInfoView, 'checkName')
basicInfoView.$('#username-input').val('test').trigger('change')
expect(basicInfoView.checkName).toHaveBeenCalled()
describe 'and finding the given person is already a user', ->
describe 'checkEmail()', ->
beforeEach ->
expect(modal.$('#facebook-account-exists-row').hasClass('hide')).toBe(true)
request = jasmine.Ajax.requests.mostRecent()
request.respondWith({status: 200, responseText: JSON.stringify({_id: 'existinguser'})})
basicInfoView.$('input[name="email"]').val('some@email.com')
basicInfoView.checkEmail()
it 'shows a message saying you are connected with Facebook, with a button for logging in', ->
expect(modal.$('#facebook-account-exists-row').hasClass('hide')).toBe(false)
loginBtn = modal.$('#facebook-login-btn')
expect(loginBtn.attr('disabled')).toBeFalsy()
loginBtn.click()
expect(loginBtn.attr('disabled')).toBeTruthy()
request = jasmine.Ajax.requests.mostRecent()
expect(request.method).toBe('POST')
expect(request.params).toBe('facebookID=abcd&facebookAccessToken=1234')
expect(request.url).toBe('/auth/login-facebook')
it 'shows checking', ->
expect(basicInfoView.$('[data-i18n="signup.checking"]').length).toBe(1)
describe 'and the user finishes signup anyway with new info', ->
beforeEach ->
forms.objectToForm(modal.$el, { email: 'some@email.com', birthdayDay: 24, birthdayMonth: 7, birthdayYear: 1988 })
modal.$('form').submit()
describe 'if email DOES exist', ->
beforeEach (done) ->
jasmine.Ajax.requests.mostRecent().respondWith({
status: 200
responseText: JSON.stringify({exists: true})
})
_.defer done
it 'upserts the values to the new user', ->
request = jasmine.Ajax.requests.mostRecent()
expect(request.method).toBe('PUT')
expect(request.url).toBe('/db/user?facebookID=abcd&facebookAccessToken=1234')
it 'says an account already exists and encourages to sign in', ->
expect(basicInfoView.$('[data-i18n="signup.account_exists"]').length).toBe(1)
expect(basicInfoView.$('.login-link[data-i18n="signup.sign_in"]').length).toBe(1)
describe 'if email DOES NOT exist', ->
beforeEach (done) ->
jasmine.Ajax.requests.mostRecent().respondWith({
status: 200
responseText: JSON.stringify({exists: false})
})
_.defer done
describe 'and finding the given person is not yet a user', ->
it 'says email looks good', ->
expect(basicInfoView.$('[data-i18n="signup.email_good"]').length).toBe(1)
describe 'checkName()', ->
beforeEach ->
expect(modal.$('#facebook-logged-in-row').hasClass('hide')).toBe(true)
request = jasmine.Ajax.requests.mostRecent()
request.respondWith({status: 404})
basicInfoView.$('input[name="name"]').val('Some Name').trigger('change')
basicInfoView.checkName()
it 'shows a message saying you are connected with Facebook', ->
expect(modal.$('#facebook-logged-in-row').hasClass('hide')).toBe(false)
it 'shows checking', ->
expect(basicInfoView.$('[data-i18n="signup.checking"]').length).toBe(1)
describe 'and the user finishes signup', ->
# does not work in travis since en.coffee is not included. TODO: Figure out workaround
# describe 'if name DOES exist', ->
# beforeEach (done) ->
# jasmine.Ajax.requests.mostRecent().respondWith({
# status: 200
# responseText: JSON.stringify({conflicts: true, suggestedName: 'test123'})
# })
# _.defer done
#
# it 'says name is taken and suggests a different one', ->
# expect(basicInfoView.$el.text().indexOf('test123') > -1).toBe(true)
describe 'if email DOES NOT exist', ->
beforeEach (done) ->
jasmine.Ajax.requests.mostRecent().respondWith({
status: 200
responseText: JSON.stringify({conflicts: false})
})
_.defer done
it 'says name looks good', ->
expect(basicInfoView.$('[data-i18n="signup.name_available"]').length).toBe(1)
describe 'onSubmitForm()', ->
it 'shows required errors for empty fields when on INDIVIDUAL path', ->
basicInfoView.$('input').val('')
basicInfoView.$('#basic-info-form').submit()
expect(basicInfoView.$('.form-group.has-error').length).toBe(3)
it 'shows required errors for empty fields when on STUDENT path', ->
modal.signupState.set('path', 'student')
modal.render()
basicInfoView.$('#basic-info-form').submit()
expect(basicInfoView.$('.form-group.has-error').length).toBe(5) # includes first and last name
describe 'submit with password', ->
beforeEach ->
modal.$('form').submit()
forms.objectToForm(basicInfoView.$el, {
email: 'some@email.com'
password: 'password'
name: 'A Username'
})
basicInfoView.$('form').submit()
it 'creates the user with the facebook attributes', ->
request = jasmine.Ajax.requests.mostRecent()
expect(request.method).toBe('PUT')
expect(_.string.startsWith(request.url, '/db/user')).toBe(true)
expect(modal.$('#signup-button').is(':disabled')).toBe(true)
expect(request.url).toBe('/db/user?facebookID=abcd&facebookAccessToken=1234')
it 'checks for email and name conflicts', ->
emailCheck = _.find(jasmine.Ajax.requests.all(), (r) -> _.string.startsWith(r.url, '/auth/email'))
nameCheck = _.find(jasmine.Ajax.requests.all(), (r) -> _.string.startsWith(r.url, '/auth/name'))
expect(_.all([emailCheck, nameCheck])).toBe(true)
describe 'a check does not pass', ->
beforeEach (done) ->
nameCheck = _.find(jasmine.Ajax.requests.all(), (r) -> _.string.startsWith(r.url, '/auth/name'))
nameCheck.respondWith({
status: 200
responseText: JSON.stringify({conflicts: false})
})
emailCheck = _.find(jasmine.Ajax.requests.all(), (r) -> _.string.startsWith(r.url, '/auth/email'))
emailCheck.respondWith({
status: 200
responseText: JSON.stringify({ exists: true })
})
_.defer done
it 're-enables the form and shows which field failed', ->
describe 'both checks do pass', ->
beforeEach (done) ->
nameCheck = _.find(jasmine.Ajax.requests.all(), (r) -> _.string.startsWith(r.url, '/auth/name'))
nameCheck.respondWith({
status: 200
responseText: JSON.stringify({conflicts: false})
})
emailCheck = _.find(jasmine.Ajax.requests.all(), (r) -> _.string.startsWith(r.url, '/auth/email'))
emailCheck.respondWith({
status: 200
responseText: JSON.stringify({ exists: false })
})
_.defer done
it 'saves the user', ->
request = jasmine.Ajax.requests.mostRecent()
expect(_.string.startsWith(request.url, '/db/user')).toBe(true)
describe 'saving the user FAILS', ->
beforeEach (done) ->
request = jasmine.Ajax.requests.mostRecent()
request.respondWith({
status: 422
responseText: JSON.stringify({
message: 'Some error happened'
})
})
_.defer(done)
it 'displays the server error', ->
expect(basicInfoView.$('.alert-danger').length).toBe(1)
describe 'saving the user SUCCEEDS', ->
beforeEach (done) ->
request = jasmine.Ajax.requests.mostRecent()
request.respondWith({
status: 200
responseText: '{}'
})
_.defer(done)
it 'signs the user up with the password', ->
request = jasmine.Ajax.requests.mostRecent()
expect(_.string.endsWith(request.url, 'signup-with-password')).toBe(true)
describe 'after signup STUDENT', ->
beforeEach (done) ->
basicInfoView.signupState.set({
path: 'student'
classCode: 'ABC'
classroom: new Classroom()
})
request = jasmine.Ajax.requests.mostRecent()
request.respondWith(responses.signupSuccess)
_.defer(done)
it 'joins the classroom', ->
request = jasmine.Ajax.requests.mostRecent()
expect(request.url).toBe('/db/classroom/~/members')
describe 'signing the user up SUCCEEDS', ->
beforeEach (done) ->
spyOn(basicInfoView, 'finishSignup')
request = jasmine.Ajax.requests.mostRecent()
request.respondWith(responses.signupSuccess)
_.defer(done)
it 'calls finishSignup()', ->
expect(basicInfoView.finishSignup).toHaveBeenCalled()
describe 'ConfirmationView', ->
confirmationView = null
beforeEach ->
modal = new CreateAccountModal()
modal.signupState.set('screen', 'confirmation')
modal.render()
jasmine.demoModal(modal)
confirmationView = modal.subviews.confirmation_view
it '(for demo testing)', ->
me.set('name', 'A Sweet New Username')
me.set('email', 'some@email.com')
confirmationView.signupState.set('ssoUsed', 'gplus')
describe 'SingleSignOnConfirmView', ->
singleSignOnConfirmView = null
beforeEach ->
modal = new CreateAccountModal()
modal.signupState.set({
screen: 'sso-confirm'
email: 'some@email.com'
})
modal.render()
jasmine.demoModal(modal)
singleSignOnConfirmView = modal.subviews.single_sign_on_confirm_view
it '(for demo testing)', ->
me.set('name', 'A Sweet New Username')
me.set('email', 'some@email.com')
singleSignOnConfirmView.signupState.set('ssoUsed', 'facebook')
describe 'CoppaDenyView', ->
coppaDenyView = null
beforeEach ->
modal = new CreateAccountModal()
modal.signupState.set({
screen: 'coppa-deny'
})
modal.render()
jasmine.demoModal(modal)
coppaDenyView = modal.subviews.coppa_deny_view
it '(for demo testing)', ->