Merge branch 'master' into production

This commit is contained in:
Matt Lott 2015-09-25 15:23:56 -07:00
commit e37c5645ef
49 changed files with 1413 additions and 343 deletions

View file

@ -26,6 +26,7 @@ module.exports = class CocoRouter extends Backbone.Router
'account/subscription': go('account/SubscriptionView') 'account/subscription': go('account/SubscriptionView')
'account/subscription/sale': go('account/SubscriptionSaleView') 'account/subscription/sale': go('account/SubscriptionSaleView')
'account/invoices': go('account/InvoicesView') 'account/invoices': go('account/InvoicesView')
'account/prepaid': go('account/PrepaidView')
'admin': go('admin/MainAdminView') 'admin': go('admin/MainAdminView')
'admin/candidates': go('admin/CandidatesView') 'admin/candidates': go('admin/CandidatesView')
@ -65,7 +66,7 @@ module.exports = class CocoRouter extends Backbone.Router
'courses/mock1/:courseID': go('courses/mock1/CourseDetailsView') 'courses/mock1/:courseID': go('courses/mock1/CourseDetailsView')
'courses': go('courses/CoursesView') 'courses': go('courses/CoursesView')
'courses/enroll(/:courseID)': go('courses/CourseEnrollView') 'courses/enroll(/:courseID)': go('courses/CourseEnrollView')
'courses/:courseID': go('courses/CourseDetailsView') 'courses/:courseID(/:courseInstanceID)': go('courses/CourseDetailsView')
'db/*path': 'routeToServer' 'db/*path': 'routeToServer'
'demo(/*subpath)': go('DemoView') 'demo(/*subpath)': go('DemoView')

View file

@ -243,3 +243,8 @@ module.exports.getCoursePraise = getCoursePraise = ->
} }
] ]
praise[_.random(0, praise.length - 1)] praise[_.random(0, praise.length - 1)]
module.exports.getPrepaidCodeAmount = getPrepaidCodeAmount = (price=999, users=0, months=0) ->
return 0 unless users > 0 and months > 0
total = price * users * months
total

View file

@ -868,6 +868,7 @@ module.exports.thangNames = thangNames =
'Nordex' 'Nordex'
'Satish' 'Satish'
'Vera' 'Vera'
'Charlotte'
] ]
'Equestrian': [ 'Equestrian': [
# Male # Male

View file

@ -31,7 +31,7 @@ module.exports = nativeDescription: "български език", englishDescri
contact: "Контакти" contact: "Контакти"
twitter_follow: "Започни да следиш" twitter_follow: "Започни да следиш"
teachers: "Учители" teachers: "Учители"
# careers: "Careers" careers: "Кариери"
modal: modal:
close: "Затвори" close: "Затвори"
@ -75,7 +75,7 @@ module.exports = nativeDescription: "български език", englishDescri
level_difficulty: "Трудност" level_difficulty: "Трудност"
campaign_beginner: "Кампания за начинаещи" campaign_beginner: "Кампания за начинаещи"
awaiting_levels_adventurer_prefix: "5 нови нива всяка седмица" # {change} awaiting_levels_adventurer_prefix: "5 нови нива всяка седмица" # {change}
awaiting_levels_adventurer: "Стани Приключенец" awaiting_levels_adventurer: "Стани Търсач на приключения"
awaiting_levels_adventurer_suffix: "за да бъдеш първият, който играе нови нива." awaiting_levels_adventurer_suffix: "за да бъдеш първият, който играе нови нива."
adjust_volume: "Настрой звук" adjust_volume: "Настрой звук"
campaign_multiplayer: "Арени за мултиплейър" campaign_multiplayer: "Арени за мултиплейър"
@ -97,7 +97,7 @@ module.exports = nativeDescription: "български език", englishDescri
logging_in: "Влизане..." logging_in: "Влизане..."
log_out: "Изход" log_out: "Изход"
forgot_password: "Забравена парола?" forgot_password: "Забравена парола?"
authenticate_gplus: "Автентикация чрез G+" authenticate_gplus: "Аутентикация чрез G+"
load_profile: "Зареди G+ профил" load_profile: "Зареди G+ профил"
finishing: "Завършване" finishing: "Завършване"
sign_in_with_facebook: "Вписване чрез Facebook" sign_in_with_facebook: "Вписване чрез Facebook"
@ -105,7 +105,7 @@ module.exports = nativeDescription: "български език", englishDescri
signup_switch: "Създаване на нов акаунт?" signup_switch: "Създаване на нов акаунт?"
signup: signup:
email_announcements: "Получава анонси по имейл" email_announcements: "Получавай анонси по имейл"
creating: "Създаване на профил..." creating: "Създаване на профил..."
sign_up: "Регистриране" sign_up: "Регистриране"
log_in: "Вход с парола" log_in: "Вход с парола"
@ -204,9 +204,9 @@ module.exports = nativeDescription: "български език", englishDescri
minute: "минута" minute: "минута"
minutes: "минути" minutes: "минути"
hour: "час" hour: "час"
hours: "часове" hours: "часа"
day: "ден" day: "ден"
days: "дни" days: "дена"
week: "седмица" week: "седмица"
weeks: "седмици" weeks: "седмици"
month: "месец" month: "месец"
@ -256,8 +256,8 @@ module.exports = nativeDescription: "български език", englishDescri
victory_new_item: "Нов Предмет" victory_new_item: "Нов Предмет"
victory_viking_code_school: "О да - това ниво беше наистина тежко! Ти или си програмист, или обезателно трябва да станеш такъв! Току що се доближи до приемането си във Викингското Училище по Програмиране, където ще научиш много нови неща и ще станеш професионален уеб програмист за 14 седмици." victory_viking_code_school: "О да - това ниво беше наистина тежко! Ти или си програмист, или обезателно трябва да станеш такъв! Току що се доближи до приемането си във Викингското Училище по Програмиране, където ще научиш много нови неща и ще станеш професионален уеб програмист за 14 седмици."
victory_become_a_viking: "Стани Викинг" victory_become_a_viking: "Стани Викинг"
# victory_bloc: "Great work! Your skills are improving, and someone's taking notice. If you've considered becoming a software developer, this may be your lucky day. Bloc is an online bootcamp that pairs you 1-on-1 with an expert mentor who will help train you into a professional developer! By beating A Mayhem of Munchkins, you're now eligible for a $500 price reduction with the code: CCRULES" victory_bloc: "Чудесна работа! Твоите умения се подобряват и някой го е забелязал! Ако смяташ да ставаш програмист, може би това е щастливият ти ден. Bloc е online bootcamp, където ще можеш да тренираш насаме с наставник и да станеш професионален програмист! Тъй като победи в A Mayhem of Munchkins, получаваш купон за намаление от $500. Кодът е: CCRULES"
# victory_bloc_cta: "Meet your mentor learn about Bloc" victory_bloc_cta: "Срещни се с наставника си - научи повече за Bloc"
guide_title: "Упътване" guide_title: "Упътване"
tome_minion_spells: "Заклинания на вашите Миньони' Spells" # Only in old-style levels. tome_minion_spells: "Заклинания на вашите Миньони' Spells" # Only in old-style levels.
tome_read_only_spells: "Read-Only Заклинания" # Only in old-style levels. tome_read_only_spells: "Read-Only Заклинания" # Only in old-style levels.
@ -401,17 +401,17 @@ module.exports = nativeDescription: "български език", englishDescri
price: "x3500 / месец" price: "x3500 / месец"
subscribe: subscribe:
# comparison_blurb: "Sharpen your skills with a CodeCombat subscription!" comparison_blurb: "Изостри уменията си в CodeCombat с абонамент!"
# feature1: "110+ basic levels across 4 worlds" feature1: "110+ основни нива в 4 свята"
# feature2: "10 powerful <strong>new heroes</strong> with unique skills!" feature2: "10 силни <strong>нови герои</strong> с уникални умения!"
# feature3: "70+ bonus levels" feature3: "70+ бонус нива"
# feature4: "<strong>3500 bonus gems</strong> every month!" feature4: "<strong>3500 скъпоценни камъни бонус</strong> всеки месец!"
feature5: "Видео уроци" feature5: "Видео уроци"
# feature6: "Premium email support" feature6: "Премиум email поддръжка"
# feature7: "Private <strong>Clans</strong>" feature7: "Частни <strong>Кланове</strong>"
# free: "Free" free: "Безплатно"
# month: "month" month: "месец"
# must_be_logged: "You must be logged in first. Please create an account or log in from the menu above." must_be_logged: "Първо трябва да сте влезли. Моля, създайте си акаунт или влезте от менюто отгоре."
subscribe_title: "Абонирай се" subscribe_title: "Абонирай се"
unsubscribe: "Прекрати абонамента" unsubscribe: "Прекрати абонамента"
confirm_unsubscribe: "Подтвърди прекратяване на абонамента" confirm_unsubscribe: "Подтвърди прекратяване на абонамента"
@ -431,61 +431,61 @@ module.exports = nativeDescription: "български език", englishDescri
parent_email_title: "Въведете email на родител?" parent_email_title: "Въведете email на родител?"
parents: "За Родители" parents: "За Родители"
parents_title: "Скъпи Родители: Вашето дете се учи да програмира. Ще му помогнете ли да продължи?" parents_title: "Скъпи Родители: Вашето дете се учи да програмира. Ще му помогнете ли да продължи?"
parents_blurb1: "Вашето дете изигра __nLevels__ нива и научи основи на програмирането. Развийте интереса у тях като им купите абонамент - така те ще могат да продължат с игрите." parents_blurb1: "Вашето дете изигра __nLevels__ нива и получи основни познания в програмирането. Развийте интереса в него като му купите абонамент - така то ще може да продължи с игрите."
parents_blurb1a: "Програмирането е важно умение което без съмнение вашето дете ще използва когато порасне. До 2020-та, основни познания по програмиране ще са необходими за повече от 77% от работните места, а софтуерните инженери ще са много търсени по света. Знаете ли че диплома в сферата на информационните технологии е предпоставка за едни от най-високите заплати в индустрията?" parents_blurb1a: "Програмирането е важно умение което без съмнение вашето дете ще използва когато порасне. До 2020-та, основни познания по програмиране ще са необходими за повече от 77% от работните места, а софтуерните инженери ще са много търсени по света. Знаете ли, че диплома в сферата на информационните технологии е предпоставка за едни от най-високите заплати в индустрията?"
parents_blurb2: "За $9.99 USD/месец, вашето дете ще получава нови задачи всяка седмица, както и персонална помощ по електронната помощ от професионални програмисти." parents_blurb2: "За $9.99 USD/месец, вашето дете ще получава нови задачи всяка седмица, както и персонална помощ по електронната поща от професионални програмисти."
parents_blurb3: "Без Риск: 100% гаранция за възстановяване на средствата, прекратяване на абонамаента с едно натискане на бутон." parents_blurb3: "Без Риск: 100% гаранция за възстановяване на средствата, прекратяване на абонамента с едно натискане на бутон."
payment_methods: "Начини на плащане" payment_methods: "Начини на плащане"
payment_methods_title: "Възможни начини на плащане" payment_methods_title: "Възможни начини на плащане"
payment_methods_blurb1: "В момента приемаме кредитни карти и Alipay." payment_methods_blurb1: "В момента приемаме кредитни карти и Alipay."
payment_methods_blurb2: "Ако желаете алтернативна форма на плащане, свържете се с нас" payment_methods_blurb2: "Ако желаете алтернативна форма на плащане, свържете се с нас"
# sale_already_subscribed: "You're already subscribed!" sale_already_subscribed: "Вече имате абонамент!"
# sale_blurb1: "Save 35%" sale_blurb1: "Спестете 35%"
# sale_blurb2: "off regular subscription price of $120 for a whole year!" # {changed} sale_blurb2: "от редовната абонаментна такса от $120 за цялата година!" # {changed}
# sale_button: "Sale!" sale_button: "Разпродажба!"
# sale_button_title: "Save 35% when you purchase a 1 year subscription" sale_button_title: "Спестете 35% като направите абонамент за 1 година"
# sale_click_here: "Click Here" sale_click_here: "Кликнете Тук"
# sale_ends: "Ends" sale_ends: "Завършва"
# sale_extended: "*Existing subscriptions will be extended by 1 year." sale_extended: "*Съществуващият абонамент ще бъде продължен с 1 година."
# sale_feature_here: "Here's what you'll get:" sale_feature_here: "Ето какво получавате:"
# sale_feature2: "Access to 9 powerful <strong>new heroes</strong> with unique skills!" sale_feature2: "Достъп до 9 силни <strong>нови герои</strong> с уникални умения!"
# sale_feature4: "<strong>42,000 bonus gems</strong> awarded immediately!" sale_feature4: "<strong>42,000 скъпоценни камъни бонус</strong> присъдени веднага!"
# sale_continue: "Ready to continue adventuring?" sale_continue: "Готови ли сте да продължите приключението?"
# sale_limited_time: "Limited time offer!" sale_limited_time: "Офертата е достъпна за ограничен период от време!"
# sale_new_heroes: "New heroes!" sale_new_heroes: "Нови герои!"
# sale_title: "Back to School Sale" sale_title: "Назад към Училищната Разпродажба"
# sale_view_button: "Buy 1 year subscription for" sale_view_button: "Купи едногодишен абонамент за"
# stripe_description: "Monthly Subscription" stripe_description: "Месечен Абонамент"
# stripe_description_year_sale: "1 Year Subscription (35% discount)" stripe_description_year_sale: "Едногодишен абонамент (35% намаление)"
# subscription_required_to_play: "You'll need a subscription to play this level." subscription_required_to_play: "Необходим ви е абонамент за да играете това ниво."
# unlock_help_videos: "Subscribe to unlock all video tutorials." unlock_help_videos: "Абонирайте се за да отключите всичките видео уроци."
# personal_sub: "Personal Subscription" # Accounts Subscription View below personal_sub: "Персонален абонамент" # Accounts Subscription View below
# loading_info: "Loading subscription information..." loading_info: "Зареждане на информацията за абонамента..."
# managed_by: "Managed by" managed_by: "Управлявана от"
# will_be_cancelled: "Will be cancelled on" will_be_cancelled: "Ще бъде отменена"
# currently_free: "You currently have a free subscription" currently_free: "В момента имате безплатен абонамент"
# currently_free_until: "You currently have a subscription until" # {changed} currently_free_until: "В момента имате абонамент до" # {changed}
# was_free_until: "You had a free subscription until" was_free_until: "Имали сте безплатен абонамент до"
# managed_subs: "Managed Subscriptions" managed_subs: "Управлявани Абонаменти"
# managed_subs_desc: "Add subscriptions for other players (students, children, etc.)" managed_subs_desc: "Добавете абонаменти на други играчи (студенти, деца и т.н.)"
# managed_subs_desc_2: "Recipients must have a CodeCombat account associated with the email address you provide." managed_subs_desc_2: "Получателите трябва да имат CodeCombat акаунт, асоцииран с email адреса, който предоставяте."
# group_discounts: "Group discounts" group_discounts: "Групови намаления"
# group_discounts_1: "We also offer group discounts for bulk subscriptions." group_discounts_1: "Също предлагаме специални намаления за групово абониране."
# group_discounts_1st: "1st subscription" group_discounts_1st: "Първи абонамент"
# group_discounts_full: "Full price" group_discounts_full: "Пълната цена"
# group_discounts_2nd: "Subscriptions 2-11" group_discounts_2nd: "Абонаменти 2-11"
# group_discounts_20: "20% off" group_discounts_20: "20% намаление"
# group_discounts_12th: "Subscriptions 12+" group_discounts_12th: "Абонаменти 12+"
# group_discounts_40: "40% off" group_discounts_40: "40% намаление"
# subscribing: "Subscribing..." subscribing: "Абониране..."
# recipient_emails_placeholder: "Enter email address to subscribe, one per line." recipient_emails_placeholder: "Въведете email адресите на абонатите, по един на ред."
# subscribe_users: "Subscribe Users" subscribe_users: "Абониране на потребители"
# users_subscribed: "Users subscribed:" users_subscribed: "Абонирани потребители:"
# no_users_subscribed: "No users subscribed, please double check your email addresses." no_users_subscribed: "Няма абонирани потребители, моля проверете email адресите отново."
# current_recipients: "Current Recipients" current_recipients: "Текущи получатели"
# unsubscribing: "Unsubscribing..." unsubscribing: "Прекратяване на абонамента..."
# subscribe_prepaid: "Click Subscribe to use prepaid code" subscribe_prepaid: "Кликнете 'Абонамент', за да използвате предплатен код"
# using_prepaid: "Using prepaid code for monthly subscription" using_prepaid: "Използване на предплатен код за месечен абонамент"
choose_hero: choose_hero:
choose_hero: "Избери си герой" choose_hero: "Избери си герой"
@ -494,7 +494,7 @@ module.exports = nativeDescription: "български език", englishDescri
default: "По подразбиране" default: "По подразбиране"
experimental: "Експериментално" experimental: "Експериментално"
python_blurb: "Прост,но мощен, идеален за начинаещи и експерти." python_blurb: "Прост,но мощен, идеален за начинаещи и експерти."
javascript_blurb: "Езикът на мрежата. (Не е същия като Java.)" javascript_blurb: "Езикът на Мрежата. (Не е същия като Java.)"
coffeescript_blurb: "По-добър синтаксис от JavaScript." coffeescript_blurb: "По-добър синтаксис от JavaScript."
clojure_blurb: "Модерен Lisp." clojure_blurb: "Модерен Lisp."
lua_blurb: "Скриптен език за игри." lua_blurb: "Скриптен език за игри."

View file

@ -1,6 +1,6 @@
module.exports = nativeDescription: "dansk", englishDescription: "Danish", translation: module.exports = nativeDescription: "dansk", englishDescription: "Danish", translation:
home: home:
slogan: "Lær at Kode ved at Spille et Spil" slogan: "Lær at kode ved at spille et spil"
no_ie: "CodeCombat kan desværre ikke køre i Internet Explorer 8 eller ældre. Beklager!" # Warning that only shows up in IE8 and older no_ie: "CodeCombat kan desværre ikke køre i Internet Explorer 8 eller ældre. Beklager!" # Warning that only shows up in IE8 and older
no_mobile: "CodeCombat er ikke designet til mobile enheder og vil måske ikke virke!" # Warning that shows up on mobile devices no_mobile: "CodeCombat er ikke designet til mobile enheder og vil måske ikke virke!" # Warning that shows up on mobile devices
play: "Spil" # The big play button that opens up the campaign view. play: "Spil" # The big play button that opens up the campaign view.
@ -98,7 +98,7 @@ module.exports = nativeDescription: "dansk", englishDescription: "Danish", trans
log_out: "Log Ud" log_out: "Log Ud"
forgot_password: "Glemt dit kodeord?" forgot_password: "Glemt dit kodeord?"
authenticate_gplus: "Forbind med G+" authenticate_gplus: "Forbind med G+"
load_profile: "Load G+ Profil" load_profile: "Indlæs G+ Profil"
finishing: "Færdiggører" finishing: "Færdiggører"
sign_in_with_facebook: "Log ind med Facebook" sign_in_with_facebook: "Log ind med Facebook"
sign_in_with_gplus: "Sign in with G+" sign_in_with_gplus: "Sign in with G+"
@ -310,8 +310,8 @@ module.exports = nativeDescription: "dansk", englishDescription: "Danish", trans
# tip_reticulating: "Reticulating spines." # tip_reticulating: "Reticulating spines."
tip_harry: "Du' en troldmand, " tip_harry: "Du' en troldmand, "
tip_great_responsibility: "Med store kodeevner kommer stort fejlfindingsansvnar." tip_great_responsibility: "Med store kodeevner kommer stort fejlfindingsansvnar."
# tip_munchkin: "If you don't eat your vegetables, a munchkin will come after you while you're asleep." tip_munchkin: "Hvis du ikke spiser dine grøntsager, kommer der en Munchkin efter dig mens du sover."
# tip_binary: "There are only 10 types of people in the world: those who understand binary, and those who don't." tip_binary: "Der findes kun 10 slags mennesker i verdenen: Dem som forstår binært, og dem som ikke gør."
# tip_commitment_yoda: "A programmer must have the deepest commitment, the most serious mind. ~ Yoda" # tip_commitment_yoda: "A programmer must have the deepest commitment, the most serious mind. ~ Yoda"
# tip_no_try: "Do. Or do not. There is no try. - Yoda" # tip_no_try: "Do. Or do not. There is no try. - Yoda"
# tip_patience: "Patience you must have, young Padawan. - Yoda" # tip_patience: "Patience you must have, young Padawan. - Yoda"
@ -334,33 +334,33 @@ module.exports = nativeDescription: "dansk", englishDescription: "Danish", trans
# tip_source_code: "I want to change the world but they would not give me the source code." # tip_source_code: "I want to change the world but they would not give me the source code."
# tip_javascript_java: "Java is to JavaScript what Car is to Carpet. - Chris Heilmann" # tip_javascript_java: "Java is to JavaScript what Car is to Carpet. - Chris Heilmann"
# tip_move_forward: "Whatever you do, keep moving forward. - Martin Luther King Jr." # tip_move_forward: "Whatever you do, keep moving forward. - Martin Luther King Jr."
# tip_google: "Have a problem you can't solve? Google it!" tip_google: "Har du et problem du ikke kan løse? Google det!"
# tip_adding_evil: "Adding a pinch of evil." tip_adding_evil: "Tilføjer et strejf af ondskab.."
# tip_hate_computers: "That's the thing about people who think they hate computers. What they really hate is lousy programmers. - Larry Niven" # tip_hate_computers: "That's the thing about people who think they hate computers. What they really hate is lousy programmers. - Larry Niven"
# tip_open_source_contribute: "You can help CodeCombat improve!" # tip_open_source_contribute: "You can help CodeCombat improve!"
# tip_recurse: "To iterate is human, to recurse divine. - L. Peter Deutsch" # tip_recurse: "To iterate is human, to recurse divine. - L. Peter Deutsch"
# tip_free_your_mind: "You have to let it all go, Neo. Fear, doubt, and disbelief. Free your mind. - Morpheus" # tip_free_your_mind: "You have to let it all go, Neo. Fear, doubt, and disbelief. Free your mind. - Morpheus"
# tip_strong_opponents: "Even the strongest of opponents always has a weakness. - Itachi Uchiha" # tip_strong_opponents: "Even the strongest of opponents always has a weakness. - Itachi Uchiha"
# tip_paper_and_pen: "Before you start coding, you can always plan with a sheet of paper and a pen." tip_paper_and_pen: "Før du starter med at programmere, kan du altid sætte dig ned med et stykke papir og blyant."
game_menu: game_menu:
# inventory_tab: "Inventory" inventory_tab: "Dine ting"
# save_load_tab: "Save/Load" save_load_tab: "Gem/Indlæs"
# options_tab: "Options" options_tab: "Indstillinger"
# guide_tab: "Guide" guide_tab: "Guide"
# guide_video_tutorial: "Video Tutorial" # guide_video_tutorial: "Video Tutorial"
# guide_tips: "Tips" guide_tips: "Råd"
multiplayer_tab: "Flere spillere" multiplayer_tab: "Flere spillere"
# auth_tab: "Sign Up" auth_tab: "Tilmeld dig"
# inventory_caption: "Equip your hero" inventory_caption: "Udrust din helt"
# choose_hero_caption: "Choose hero, language" choose_hero_caption: "Vælg helt, sprog"
# save_load_caption: "... and view history" save_load_caption: "... og se historie"
# options_caption: "Configure settings" options_caption: "Ændre indstillinger"
# guide_caption: "Docs and tips" # guide_caption: "Docs and tips"
# multiplayer_caption: "Play with friends!" multiplayer_caption: "Spil med venner!"
# auth_caption: "Save your progress." auth_caption: "Gem dit spil."
# leaderboard: leaderboard:
# leaderboard: "Leaderboard" # leaderboard: "Leaderboard"
# view_other_solutions: "View Leaderboards" # view_other_solutions: "View Leaderboards"
# scores: "Scores" # scores: "Scores"
@ -371,7 +371,7 @@ module.exports = nativeDescription: "dansk", englishDescription: "Danish", trans
# time: "Time" # time: "Time"
# damage_taken: "Damage Taken" # damage_taken: "Damage Taken"
# damage_dealt: "Damage Dealt" # damage_dealt: "Damage Dealt"
# difficulty: "Difficulty" difficulty: "Sværhedsgrad"
# gold_collected: "Gold Collected" # gold_collected: "Gold Collected"
# inventory: # inventory:
@ -699,7 +699,7 @@ module.exports = nativeDescription: "dansk", englishDescription: "Danish", trans
contact_us: "Kontakt CodeCombat" contact_us: "Kontakt CodeCombat"
welcome: "Godt at høre fra dig! Brug denne formular til at sende os en email. " welcome: "Godt at høre fra dig! Brug denne formular til at sende os en email. "
forum_prefix: "For noget offentligt, prøv venligst " forum_prefix: "For noget offentligt, prøv venligst "
forum_page: "vores forum" forum_page: "Vores forum"
forum_suffix: " istedet." forum_suffix: " istedet."
# faq_prefix: "There's also a" # faq_prefix: "There's also a"
# faq: "FAQ" # faq: "FAQ"

View file

@ -1129,6 +1129,7 @@
recently_played: "Recently Played" recently_played: "Recently Played"
no_recent_games: "No games played during the past two weeks." no_recent_games: "No games played during the past two weeks."
payments: "Payments" payments: "Payments"
prepaid: "Prepaid"
purchased: "Purchased" purchased: "Purchased"
sale: "Sale" sale: "Sale"
subscription: "Subscription" subscription: "Subscription"
@ -1159,6 +1160,14 @@
retrying: "Server error, retrying." retrying: "Server error, retrying."
success: "Successfully paid. Thanks!" success: "Successfully paid. Thanks!"
account_prepaid:
purchase_code: "Purchase a Subscription Code"
purchase_amount: "Amount"
purchase_total: "Total"
purchase_button: "Submit Purchase"
your_codes: "Your Codes:"
redeem_codes: "Redeem a Subscription Code"
loading_error: loading_error:
could_not_load: "Error loading from server" could_not_load: "Error loading from server"
connection_failure: "Connection failed." connection_failure: "Connection failed."

View file

@ -300,11 +300,11 @@ module.exports = nativeDescription: "suomi", englishDescription: "Finnish", tran
tip_tell_friends: "Pidätkö CodeCombatista? Kerro meistä myös ystävillesi!" tip_tell_friends: "Pidätkö CodeCombatista? Kerro meistä myös ystävillesi!"
tip_beta_launch: "CodeCombat avattiin betatestaukseen lokakuussa 2013." tip_beta_launch: "CodeCombat avattiin betatestaukseen lokakuussa 2013."
tip_think_solution: "Mieti ratkaisua, älä ongelmaa." tip_think_solution: "Mieti ratkaisua, älä ongelmaa."
# tip_theory_practice: "In theory, there is no difference between theory and practice. But in practice, there is. - Yogi Berra" tip_theory_practice: "Teoriassa teoria ja käytäntö ovat sama asia. Käytännössä näin ei ole. - Yogi Berra"
# tip_error_free: "There are two ways to write error-free programs; only the third one works. - Alan Perlis" tip_error_free: "Virheettömiä ohjelmia voi kirjoittaa kahdella eri tavalla. Ikävä kyllä kolmas tapa on ainoa toimiva. - Alan Perlis"
# tip_debugging_program: "If debugging is the process of removing bugs, then programming must be the process of putting them in. - Edsger W. Dijkstra" tip_debugging_program: "Jos debuggaus tarkoittaa virheiden poistamista ohjelmasta, niin ohjelmoinnin on tarkoitettava niiden lisäämistä. - Edsger W. Dijkstra"
# tip_forums: "Head over to the forums and tell us what you think!" tip_forums: "Tulepa keskustelupalstalle kertomaan mielipiteesi!"
# tip_baby_coders: "In the future, even babies will be Archmages." tip_baby_coders: "Tulevaisuudessa jopa vauvoista tulee Arkkimaageja."
# tip_morale_improves: "Loading will continue until morale improves." # tip_morale_improves: "Loading will continue until morale improves."
# tip_all_species: "We believe in equal opportunities to learn programming for all species." # tip_all_species: "We believe in equal opportunities to learn programming for all species."
# tip_reticulating: "Reticulating spines." # tip_reticulating: "Reticulating spines."

View file

@ -1,12 +1,12 @@
module.exports = nativeDescription: "Português do Brasil", englishDescription: "Portuguese (Brazil)", translation: module.exports = nativeDescription: "Português do Brasil", englishDescription: "Portuguese (Brazil)", translation:
home: home:
slogan: "Aprenda a programar enquanto se diverte jogando." slogan: "Aprenda a programar enquanto se diverte jogando."
no_ie: "CodeCombat não roda em versões mais antigas que o Internet Explorer 10. Desculpe!" # Warning that only shows up in IE8 and older no_ie: "O CodeCombat não roda em versões mais antigas que o Internet Explorer 10. Desculpe!" # Warning that only shows up in IE8 and older
no_mobile: "CodeCombat não foi projetado para dispositivos móveis e pode não funcionar!" # Warning that shows up on mobile devices no_mobile: "O CodeCombat não foi projetado para dispositivos móveis e pode não funcionar!" # Warning that shows up on mobile devices
play: "Jogar" # The big play button that opens up the campaign view. play: "Jogar" # The big play button that opens up the campaign view.
old_browser: "Ops, seu navegador é muito antigo para rodar o CodeCombat. Desculpe!" # Warning that shows up on really old Firefox/Chrome/Safari old_browser: "Ops, seu navegador é muito antigo para rodar o CodeCombat. Desculpe!" # Warning that shows up on really old Firefox/Chrome/Safari
old_browser_suffix: "Você pode tentar de qualquer forma, mas provavelmente não irá funcionar." old_browser_suffix: "Você pode tentar de qualquer forma, mas provavelmente não irá funcionar."
ipad_browser: "Más notícias:CodeCombat não é executado no navegador do iPad. Boas notícias: Nosso app nativo do iPad esta esperando a aprovação da Apple." ipad_browser: "Más notícias: O CodeCombat não é executado no navegador do iPad. Boas notícias: Nosso app nativo do iPad está esperando a aprovação da Apple."
campaign: "Campanha" campaign: "Campanha"
for_beginners: "Para Iniciantes" for_beginners: "Para Iniciantes"
multiplayer: "Multijogador" # Not currently shown on home page multiplayer: "Multijogador" # Not currently shown on home page
@ -46,12 +46,12 @@ module.exports = nativeDescription: "Português do Brasil", englishDescription:
pitch_body: "Desenvolvemos o CodeCombat em Inglês, mas já temos jogadores de todo o mundo. Muitos deles querem jogar em Português Brasileiro por não falar Inglês, por isso, se você conhece os dois idiomas, por favor, considere inscrever-se no programa para Diplomata e assim ajudar a traduzir tanto o site do CodeCombat quanto todos os estágios para o Português Brasileiro." pitch_body: "Desenvolvemos o CodeCombat em Inglês, mas já temos jogadores de todo o mundo. Muitos deles querem jogar em Português Brasileiro por não falar Inglês, por isso, se você conhece os dois idiomas, por favor, considere inscrever-se no programa para Diplomata e assim ajudar a traduzir tanto o site do CodeCombat quanto todos os estágios para o Português Brasileiro."
missing_translations: "Até que possamos traduzir todo o conteúdo para o Português Brasileiro, você lerá o texto em Inglês quando a versão em Português Brasileiro não estiver disponível." missing_translations: "Até que possamos traduzir todo o conteúdo para o Português Brasileiro, você lerá o texto em Inglês quando a versão em Português Brasileiro não estiver disponível."
learn_more: "Saiba mais sobre ser um Diplomata" learn_more: "Saiba mais sobre ser um Diplomata"
subscribe_as_diplomat: "Assinar como um Diplomata" subscribe_as_diplomat: "Inscrever-se como um Diplomata"
play: play:
play_as: "Jogar Como " # Ladder page play_as: "Jogar Como " # Ladder page
spectate: "Assistir" # Ladder page spectate: "Assistir" # Ladder page
players: "jogadores" # Hover over a level on /play players: "Jogadores" # Hover over a level on /play
hours_played: "Horas jogadas" # Hover over a level on /play hours_played: "Horas jogadas" # Hover over a level on /play
items: "Itens" # Tooltip on item shop button from /play items: "Itens" # Tooltip on item shop button from /play
unlock: "Desbloquear" # For purchasing items and heroes unlock: "Desbloquear" # For purchasing items and heroes
@ -128,7 +128,7 @@ module.exports = nativeDescription: "Português do Brasil", englishDescription:
common: common:
back: "Voltar" # When used as an action verb, like "Navigate backward" back: "Voltar" # When used as an action verb, like "Navigate backward"
continue: "Continue" # When used as an action verb, like "Continue forward" continue: "Continuar" # When used as an action verb, like "Continue forward"
loading: "Carregando..." loading: "Carregando..."
saving: "Salvando..." saving: "Salvando..."
sending: "Enviando..." sending: "Enviando..."
@ -300,7 +300,7 @@ module.exports = nativeDescription: "Português do Brasil", englishDescription:
tip_tell_friends: "Está gostando do CodeCombat? Divulgue para os seus amigos!" tip_tell_friends: "Está gostando do CodeCombat? Divulgue para os seus amigos!"
tip_beta_launch: "CodeCombat lançou sua versão beta em outubro de 2013." tip_beta_launch: "CodeCombat lançou sua versão beta em outubro de 2013."
tip_think_solution: "Pense na solução, não no problema." tip_think_solution: "Pense na solução, não no problema."
tip_theory_practice: "Na teoria, não existe diferença entre teoria e prática. Mas, na prática, . - Yogi Berra" tip_theory_practice: "Na teoria, não existe diferença entre teoria e prática. Mas, na prática, existe. - Yogi Berra"
tip_error_free: "Existem duas formas de escrever programas sem erros; apenas a terceira funciona. - Alan Perlis" tip_error_free: "Existem duas formas de escrever programas sem erros; apenas a terceira funciona. - Alan Perlis"
tip_debugging_program: "Se depurar é o processo de remover erros, então programar deve ser o processo de adicioná-los. - Edsger W. Dijkstra" tip_debugging_program: "Se depurar é o processo de remover erros, então programar deve ser o processo de adicioná-los. - Edsger W. Dijkstra"
tip_forums: "Vá aos fóruns e diga-nos o que você pensa!" tip_forums: "Vá aos fóruns e diga-nos o que você pensa!"
@ -422,7 +422,7 @@ module.exports = nativeDescription: "Português do Brasil", englishDescription:
sorry_to_see_you_go: "É uma pena ver você indo embora! Por favor, diga o que podemos fazer para melhorar." sorry_to_see_you_go: "É uma pena ver você indo embora! Por favor, diga o que podemos fazer para melhorar."
unsubscribe_feedback_placeholder: "Oh, o que nós fizemos?" unsubscribe_feedback_placeholder: "Oh, o que nós fizemos?"
parent_button: "Pergunte aos seus pais" parent_button: "Pergunte aos seus pais"
parent_email_description: "Nós enviaremos um e-mail para eles adquirir para você uma assinatura do CodeCombat." parent_email_description: "Nós enviaremos um e-mail para eles adquirirem uma assinatura do CodeCombat para você."
parent_email_input_invalid: "Endereço de e-mail inválido." parent_email_input_invalid: "Endereço de e-mail inválido."
parent_email_input_label: "Endereço de e-mail dos pais" parent_email_input_label: "Endereço de e-mail dos pais"
parent_email_input_placeholder: "Informe o e-mail dos pais" parent_email_input_placeholder: "Informe o e-mail dos pais"
@ -445,15 +445,15 @@ module.exports = nativeDescription: "Português do Brasil", englishDescription:
sale_button: "Venda!" sale_button: "Venda!"
sale_button_title: "Economize 35% quando você adquirir a assinatura de 1 ano" sale_button_title: "Economize 35% quando você adquirir a assinatura de 1 ano"
sale_click_here: "Clique Aqui" sale_click_here: "Clique Aqui"
# sale_ends: "Ends" sale_ends: "Termina"
sale_extended: "*Assinaturas existentes serão extendidas por 1 ano." sale_extended: "*Assinaturas existentes serão extendidas por 1 ano."
# sale_feature_here: "Here's what you'll get:" sale_feature_here: "O que está incluso:"
sale_feature2: "Acesso a 9 poderosos <strong>novos heróis</strong> com atributos únicos!" sale_feature2: "Acesso a 9 poderosos <strong>novos heróis</strong> com atributos únicos!"
# sale_feature4: "<strong>42,000 bonus gems</strong> awarded immediately!" sale_feature4: "<strong>42,000 gemas bônus</strong> entregues imediatamente!"
# sale_continue: "Ready to continue adventuring?" sale_continue: "Pronto para continuar a aventura?"
# sale_limited_time: "Limited time offer!" sale_limited_time: "Oferta por tempo limitado!"
sale_new_heroes: "Novos heróis!" sale_new_heroes: "Novos heróis!"
# sale_title: "Back to School Sale" sale_title: "Promoção de volta às aulas"
# sale_view_button: "Buy 1 year subscription for" # sale_view_button: "Buy 1 year subscription for"
stripe_description: "Inscrição Mensal" stripe_description: "Inscrição Mensal"
stripe_description_year_sale: "Assinatura de 1 Ano (35% de desconto" stripe_description_year_sale: "Assinatura de 1 Ano (35% de desconto"
@ -610,7 +610,7 @@ module.exports = nativeDescription: "Português do Brasil", englishDescription:
intro_1: "CodeCombat é um jogo online que ensina programação. Estudantes criam código em linguagens de programação usadas na vida real." intro_1: "CodeCombat é um jogo online que ensina programação. Estudantes criam código em linguagens de programação usadas na vida real."
intro_2: "Não é necessário ter experiência!" intro_2: "Não é necessário ter experiência!"
free_title: "Quanto custa?" free_title: "Quanto custa?"
cost_china: "CodeCombat na China é gratuito para os primeiros cinco níveis, depois disso o valor mensal para ter acesso a mais de 140 níveis nos nossos servidores exclusivos na China é de $9.99 dólares americanos." cost_china: "CodeCombat na China é gratuito para os primeiros cinco níveis, depois disso o valor mesal para ter acesso a mais de 140 níveis nos nossos servidores exclusivos na China é de $9.99 dólares americanos."
free_1: "CodeCombat Basic é gratuito! Há mais de 80 níveis gratuitos que cobrem todos os conceitos." # {change} free_1: "CodeCombat Basic é gratuito! Há mais de 80 níveis gratuitos que cobrem todos os conceitos." # {change}
free_2: "Uma assinatura mensal dá acesso aos vídeos tutoriais e mais níveis para praticar." free_2: "Uma assinatura mensal dá acesso aos vídeos tutoriais e mais níveis para praticar."
teacher_subs_title: "Professores recebem assinaturas gratuitas!" teacher_subs_title: "Professores recebem assinaturas gratuitas!"
@ -632,58 +632,58 @@ module.exports = nativeDescription: "Português do Brasil", englishDescription:
monitor_progress_3: "Para adicionar estudantes, envie-os o link de convite para seu clã, que está na" monitor_progress_3: "Para adicionar estudantes, envie-os o link de convite para seu clã, que está na"
monitor_progress_4: "página." monitor_progress_4: "página."
monitor_progress_5: "Depois que eles se juntarem ao seu Clã, você verá um resumo do progresso dos estudantes na página do seu Clã." monitor_progress_5: "Depois que eles se juntarem ao seu Clã, você verá um resumo do progresso dos estudantes na página do seu Clã."
# private_clans_1: "Private Clans provide increased privacy and detailed progress information for each student." private_clans_1: "Clãs privados proporcionam mais pricacidade e informações de progresso detalhadas para cada estudante."
# private_clans_2: "To create a private Clan, check the 'Make clan private' checkbox when creating a" private_clans_2: "Para criar um Clã privado, marque a caixa 'Tornar Clã privado' ao criar um"
private_clans_3: "." private_clans_3: "."
who_for_title: "Para quem é indicado o CodeCombat?" who_for_title: "Para quem é indicado o CodeCombat?"
who_for_1: "Nós recomendamos CodeCombat para estudantes a partir de 9 anos de idade. Nenhuma experiência anterior em programação é necessária." who_for_1: "Nós recomendamos CodeCombat para estudantes a partir de 9 anos de idade. Nenhuma experiência anterior em programação é necessária."
# who_for_2: "We've designed CodeCombat to appeal to both boys and girls." who_for_2: "Nós projetamos o CodeCombat para ser atraente à meninos e meninas."
# material_title: "How much material is there?" material_title: "Quanto material existe?"
# material_china: "Approximately 40 hours of gameplay spread over 180+ subscriber-only levels so far." material_china: "Aproximadamente 40 horas de jogo espalhadas por mais de 180 níveis restritos até agora."
# material_1: "Approximately 25 hours of free content and an additional 15 hours of subscriber content." material_1: "Aproximadamente 25 horas de conteúdo livre e 15 horas adicionais de conteúdo restrito."
# concepts_title: "What concepts are covered?" concepts_title: "Que conceitos são abordados?"
# how_much_title: "How much does a monthly subscription cost?" how_much_title: "Quanto custa uma assinatura mensal?"
# how_much_1: "A" how_much_1: "Uma"
how_much_2: "assinatura mensal" how_much_2: "assinatura mensal"
# how_much_3: "costs $9.99, and can be cancelled anytime." how_much_3: "custa $9.99, e pode ser cancelada a qualquer momento."
# how_much_4: "Additionally, we provide discounts for larger groups:" how_much_4: "Além disso, nós provemos descontos para grupos maiores:"
# how_much_5: "We accept discounted one-time purchases and yearly subscription purchases for groups, such as a class or school. Please contact" # how_much_5: "We accept discounted one-time purchases and yearly subscription purchases for groups, such as a class or school. Please contact"
how_much_6: "para mais detalhes." how_much_6: "para mais detalhes."
more_info_title: "Onde eu posso encontrar mais informações?" more_info_title: "Onde eu posso encontrar mais informações?"
# more_info_1: "Our" more_info_1: "Nosso"
# more_info_2: "teachers forum" more_info_2: "fórum de professores"
# more_info_3: "is a good place to connect with fellow educators who are using CodeCombat." more_info_3: "é um bom lugar para se conectar com ótimos educadores que utilizam o CodeCombat."
sys_requirements_title: "Requisitos de Sistema" sys_requirements_title: "Requisitos de Sistema"
sys_requirements_1: "Pelo motivo de CodeCombat ser um jogo, é mais intenso para ser processado em computadores do que tutoriais em vídeo ou escritos. Estamos otimizando para que rode rapidamente em todos navegadores modernos e também em computadores antigos, assim todos podem jogar. Sendo assim, aqui estão as nossas sugestões para tirar o máximo proveito da experiência de CodeCombat:" # {change} sys_requirements_1: "Pelo motivo de CodeCombat ser um jogo, é mais intenso para ser processado em computadores do que tutoriais em vídeo ou escritos. Estamos otimizando para que rode rapidamente em todos navegadores modernos e também em computadores antigos, assim todos podem jogar. Sendo assim, aqui estão as nossas sugestões para tirar o máximo proveito da experiência de CodeCombat:" # {change}
sys_requirements_2: "Use versões novas dos navegadores Chrome ou Firefox." # {change} sys_requirements_2: "Use versões novas dos navegadores Chrome ou Firefox." # {change}
teachers_survey: teachers_survey:
# title: "Teacher Survey" title: "Pesquisa de professor"
# must_be_logged: "You must be logged in first. Please create an account or log in from the menu above." must_be_logged: "Você precisa fazer login primeiro. Por favor, crie uma conta ou faça login no menu acima."
# retrieving: "Retrieving information..." retrieving: "Recuperando informações..."
# being_reviewed_1: "Your application for a free trial subscription is being" being_reviewed_1: "Sua solicitação de teste grátis de assinatura está sendo"
# being_reviewed_2: "reviewed." being_reviewed_2: "revisada."
# approved_1: "Your application for a free trial subscription was" approved_1: "Sua solicitação de teste grátis de assinatura foi"
# approved_2: "approved." approved_2: "aprovada."
# approved_3: "Further instructions have been sent to" approved_3: "Mais intruções foram enviadas para"
# denied_1: "Your application for a free trial subscription has been" denied_1: "Sua solicitação de teste grátis de assinatura foi"
# denied_2: "denied." denied_2: "negada."
# contact_1: "Please contact" contact_1: "Por favor, entre em contato"
# contact_2: "if you have further questions." contact_2: "caso você tenha dúvidas no futuro."
# description_1: "We offer free subscriptions to teachers for evaluation purposes. You can find more information on our" description_1: "Nós oferecemos assinaturas grátis à professores para fins de avaliação. Você pode encontrar mais informações na nossa"
description_2: "professores" description_2: "professores"
description_3: "página." description_3: "página."
# description_4: "Please fill out this quick survey and well email you setup instructions." description_4: "Por favor, preencha esta rápida pesquisa e nós o enviaremos as intruções de instalação por email."
email: "Endereço de email" email: "Endereço de email"
school: "Nome da Escola" school: "Nome da Escola"
location: "Nome da Cidade" location: "Nome da Cidade"
# age_students: "How old are your students?" age_students: "Qual a idade dos seus alunos?"
# under: "Under" under: "Embaixo"
other: "Outro:" other: "Outro:"
# amount_students: "How many students do you teach?" amount_students: "Quantos alunos você ensina?"
# hear_about: "How did you hear about CodeCombat?" hear_about: "Como você ouviu falar do CodeCombat?"
# fill_fields: "Please fill out all fields." fill_fields: "Por favor, preencha todos os campos."
# thanks: "Thanks! We'll send you setup instructions shortly." thanks: "Obrigado! Enviaremos suas instruções de instalação em breve."
versions: versions:
save_version_title: "Salvar nova versão" save_version_title: "Salvar nova versão"
@ -693,7 +693,7 @@ module.exports = nativeDescription: "Português do Brasil", englishDescription:
cla_url: "CLA" cla_url: "CLA"
cla_suffix: "." cla_suffix: "."
cla_agree: "EU CONCORDO" cla_agree: "EU CONCORDO"
# owner_approve: "An owner will need to approve it before your changes will become visible." owner_approve: "Outro dono terá de aprovar isso antes de suas alterações se tornarem visíveis."
contact: contact:
contact_us: "Contate-nos" contact_us: "Contate-nos"
@ -948,8 +948,8 @@ module.exports = nativeDescription: "Português do Brasil", englishDescription:
pop_i18n: "Popular I18N" pop_i18n: "Popular I18N"
tasks: "Tarefas" tasks: "Tarefas"
clear_storage: "Limpar suas alterações locais" clear_storage: "Limpar suas alterações locais"
# add_system_title: "Add Systems to Level" add_system_title: "Adicionar sistemas ao nível"
# done_adding: "Done Adding" done_adding: "Concluir adição"
article: article:
edit_btn_preview: "Prever" edit_btn_preview: "Prever"
@ -1072,7 +1072,7 @@ module.exports = nativeDescription: "Português do Brasil", englishDescription:
fight: "Lutem!" fight: "Lutem!"
watch_victory: "Assista sua vitória" watch_victory: "Assista sua vitória"
defeat_the: "Derrote" defeat_the: "Derrote"
# tournament_started: ", started" tournament_started: ", iniciado"
tournament_ends: "Fim do torneio" tournament_ends: "Fim do torneio"
tournament_ended: "Torneio encerrado" tournament_ended: "Torneio encerrado"
tournament_rules: "Regras do Torneio" tournament_rules: "Regras do Torneio"
@ -1084,11 +1084,11 @@ module.exports = nativeDescription: "Português do Brasil", englishDescription:
rules: "Regras" rules: "Regras"
winners: "Vencedores" winners: "Vencedores"
league: "Liga" league: "Liga"
# red_ai: "Red AI" # "Red AI Wins", at end of multiplayer match playback red_ai: "AI Vermelho" # "Red AI Wins", at end of multiplayer match playback
# blue_ai: "Blue AI" blue_ai: "AI Azul"
# wins: "Wins" # At end of multiplayer match playback wins: "Vence" # At end of multiplayer match playback
# humans: "Red" # Ladder page display team name humans: "Vermelho" # Ladder page display team name
# ogres: "Blue" ogres: "Azul"
user: user:
stats: "Estatísticas" stats: "Estatísticas"
@ -1223,21 +1223,21 @@ module.exports = nativeDescription: "Português do Brasil", englishDescription:
# user_polls_record: "Poll Voting History" # user_polls_record: "Poll Voting History"
concepts: concepts:
# advanced_strings: "Advanced Strings" advanced_strings: "Strings avançadas"
algorithms: "Algorítmos" algorithms: "Algorítmos"
# arguments: "Arguments" arguments: "Argumentos"
# arithmetic: "Arithmetic" arithmetic: "Aritmética"
arrays: "Arrays" arrays: "Arrays"
basic_syntax: "Sintaxe Básica" basic_syntax: "Sintaxe Básica"
boolean_logic: "Lógica Booleana" boolean_logic: "Lógica Booleana"
# break_statements: "Break Statements" break_statements: "Comandos Break"
classes: "Classes" classes: "Classes"
# continue_statements: "Continue Statements" continue_statements: "Comandos continue"
for_loops: "Laço For" for_loops: "Laço For"
functions: "Funções" functions: "Funções"
# graphics: "Graphics" graphics: "Gráficos"
# if_statements: "If Statements" if_statements: "Comandos se"
# input_handling: "Input Handling" input_handling: "Tratamento de entrada"
math_operations: "Operações Matemáticas" math_operations: "Operações Matemáticas"
object_literals: "Objetos Literais" object_literals: "Objetos Literais"
parameters: "Parâmetros" parameters: "Parâmetros"
@ -1260,8 +1260,8 @@ module.exports = nativeDescription: "Português do Brasil", englishDescription:
guide: guide:
temp: "Temp" temp: "Temp"
# temp: temp:
# ace_of_coders_tournament: "New: play in the Ace of Coders tournament now!" ace_of_coders_tournament: "Novo: jogue no torneir Era dos Programadores agora!"
multiplayer: multiplayer:
multiplayer_title: "Configurações de Multijogador" # We'll be changing this around significantly soon. Until then, it's not important to translate. multiplayer_title: "Configurações de Multijogador" # We'll be changing this around significantly soon. Until then, it's not important to translate.

14
app/models/Prepaid.coffee Normal file
View file

@ -0,0 +1,14 @@
CocoModel = require './CocoModel'
schema = require 'schemas/models/prepaid.schema'
module.exports = class Prepaid extends CocoModel
@className: "Prepaid"
urlRoot: '/db/prepaid'
openSpots: ->
@get('maxRedeemers') - @get('redeemers')?.length
userHasRedeemed: (userID) ->
for redeemer in @get('redeemers')
return redeemer.date if redeemer.userID is userID
return null

View file

@ -0,0 +1,3 @@
#users, #months
max-width: 100px

View file

@ -23,4 +23,6 @@ block content
a.btn.btn-lg.btn-primary(href="/account/payments", data-i18n="account.payments") a.btn.btn-lg.btn-primary(href="/account/payments", data-i18n="account.payments")
li.list-group-item li.list-group-item
a.btn.btn-lg.btn-primary(href="/account/subscription", data-i18n="account.subscription") a.btn.btn-lg.btn-primary(href="/account/subscription", data-i18n="account.subscription")
li.list-group-item
a.btn.btn-lg.btn-primary(href="/account/prepaid") Prepaid Codes

View file

@ -0,0 +1,19 @@
extends /templates/core/modal-base
block modal-header-content
h3 Prepaid Code Details (#{ppc.get('code')})
block modal-body-content
if redeemedOn
p You redeemed this code: #{redeemedOn}
else
if ppc.openSpots()
p: strong Adds #{ppc.get('properties').months} month(s) to your current subscription.
p You can redeem this code.
else
p You cannot redeem this code.
block modal-footer-content
button#close.btn.btn-primary(type="button", data-dismiss="modal") Cancel
if !redeemedOn && ppc.openSpots() > 0
button#redeem.btn.btn-primary(type="button", data-dismiss="modal") Redeem Code To My Account

View file

@ -0,0 +1,106 @@
extends /templates/base
block content
if me.get('anonymous')
p(data-i18n="account_settings.not_logged_in") Log in or create an account to change your settings.
else
ol.breadcrumb
li
a(href="/")
span.glyphicon.glyphicon-home
li
a(href="/account", data-i18n="nav.account")
li.active(data-i18n="account.prepaid")
.row
.col-md-12
.panel.panel-default
.panel-heading
.panel-title
a(data-toggle="collapse" href="#purchasepanel")
span(data-i18n="account_prepaid.purchase_code")
.panel-collapse.collapse(class=ppcQuery ? "": "in")#purchasepanel
.panel-body
p Subscription Codes can be redeemed to add premium subscription time to one or more Code Combat accounts.
p Each Code Combat account can only redeem a particular Subscription Code once.
p Subscription Code months will be added to the end of any existing subscription on the account.
.form-horizontal
.form-group
label.control-label.col-md-2(for="users") Users
.col-md-2
input#users.form-control(name="users", type="number", value="#{purchase.users}", min=1)
.form-group
label.control-label.col-md-2(for="months") Months
.col-md-2
input#months.form-control(name="months", type="number", value="#{purchase.months}", min=1)
.form-group
label.control-label.col-md-2(data-i18n="account_prepaid.purchase_total")
.col-md-10
p.form-control-static $
span#total #{purchase.total}
button#purchase-button.btn.btn-success.pull-right(data-i18n="account_prepaid.purchase_button")
.row
.col-md-12
.panel.panel-default
.panel-heading
.panel-title
a(data-toggle="collapse" href="#redeempanel")
span(data-i18n="account_prepaid.redeem_codes")
.panel-collapse.collapse.in#redeempanel
.panel-body
p
span.spr Prepaid Code:
input.input-ppc(name="ppc", type="text", value="#{ppc}", required)
if ppcInfo && ppcInfo.length > 0
p
each info in ppcInfo
div
!= info
p
span.spr
button.btn.btn-info.btn-check-code Lookup prepaid code
span
button.btn.btn-success.btn-redeem-code Apply to your account
.row
.col-md-12
.panel.panel-default
.panel-heading
.panel-title
a(data-toggle="collapse" href="#codeslist")
span(data-i18n="account_prepaid.your_codes")
.panel-collapse.collapse.in#codeslist
.panel-body
if codes && codes.length
table.table.table-striped
tr
th
span(title="You can copy the code's link and send it to someone.") Code
span.glyphicon.glyphicon-question-sign(aria-hidden="true")
th Months
th Quantity
th Status
for code in codes.models
if code.get('type') === 'terminal_subscription'
- var owner = (code.get('creator') == me.id ? true : false)
- var properties = code.get('properties')
- var redeemers = code.get('redeemers')
if redeemers
- var redeemed = redeemers.length
else
- var redeemed = '0'
tr
td
a(href="/account/prepaid?_ppc=#{code.get('code')}")= code.get('code')
td= properties.months || '-'
if owner
td= code.get('maxRedeemers') - redeemed
else
td -
if owner
td Purchased
else
td Redeemed
else
p No codes yet!

View file

@ -119,6 +119,15 @@ block content
else else
button.start-subscription-button.btn.btn-lg.btn-success(data-i18n="subscribe.subscribe_title") Subscribe button.start-subscription-button.btn.btn-lg.btn-success(data-i18n="subscribe.subscribe_title") Subscribe
// - Prepaid Codes
.panel.panel-default
.panel-heading
h3 Prepaid Codes
.panel-body
p
span You can
a(href="/account/prepaid") purchase a prepaid code
span that can be applied to your own account or given to others.
//- Sponsored Subscriptions //- Sponsored Subscriptions
.panel.panel-default .panel.panel-default

View file

@ -63,6 +63,16 @@ block content
if freeSubLink if freeSubLink
input#free-sub-input(type="text", readonly, value="#{freeSubLink}") input#free-sub-input(type="text", readonly, value="#{freeSubLink}")
.form-inline
.form-group
label(for="users") Users
input#users.form-control(name="users", type="number", min=1)
.form-group
label(for="months") Months
input#months.form-control(name="months", type="number", min=1)
a#terminal-create.btn.btn-default Create Terminal Subscription Code
hr hr
h3 Achievements h3 Achievements

View file

@ -37,6 +37,8 @@ block header
a(href="/account/payments", data-i18n="account.payments") a(href="/account/payments", data-i18n="account.payments")
li li
a(href="/account/subscription", data-i18n="account.subscription") a(href="/account/subscription", data-i18n="account.subscription")
li
a(href="/account/prepaid") Prepaid Codes
li li
a#logout-button(data-i18n="login.log_out") a#logout-button(data-i18n="login.log_out")

View file

@ -2,15 +2,32 @@ extends /templates/base
block content block content
//- DO NOT localize / i18n
div div
span *UNDER CONSTRUCTION, send feedback to span *UNDER CONSTRUCTION, send feedback to
a.spl(href='mailto:team@codecombat.com') team@codecombat.com a.spl(href='mailto:team@codecombat.com') team@codecombat.com
div(style='border-bottom: 1px solid black;') div(style='border-bottom: 1px solid black;')
if me.isAnonymous() if (noCourseInstance || noCourseInstanceSelected) && course
h1 TODO: logged out h1= course.get('name')
if noCourseInstance
p You are not enrolled in this course.
p
span.spr Please visit the
a.spr(href="/courses") courses
span page to enroll.
else if noCourseInstanceSelected
p Select one of your classes
.container-fluid
.row
.col-md-6
select.form-control.select-instance
each courseInstance in courseInstances
if courseInstance.get('name')
option(value="#{courseInstance.id}")= courseInstance.get('name')
else
option(value="#{courseInstance.id}") *unnamed*
.col-md-6
button.btn.btn-success.btn-select-instance Select
else if !course || !courseInstance else if !course || !courseInstance
h1 Loading... h1 Loading...
else else
@ -61,21 +78,28 @@ mixin progress-summary-stats
td td
if courseInstance if courseInstance
div #{courseInstance.get('members').length} div #{courseInstance.get('members').length}
if instanceStats
tr tr
td Average level play time: td Average level play time:
td TODO if instanceStats.averageLevelPlaytime > 0
td= moment.duration(instanceStats.averageLevelPlaytime, "seconds").humanize()
else
td 0
tr tr
td Total play time: td Total play time:
td TODO if instanceStats.totalPlayTime > 0
td= moment.duration(instanceStats.totalPlayTime, "seconds").humanize()
else
td 0
tr tr
td Average levels completed: td Average levels completed:
td TODO td #{instanceStats.averageLevelsCompleted.toFixed(2)}
tr tr
td Total levels completed: td Total levels completed:
td TODO td= instanceStats.totalLevelsCompleted
tr tr
td Furthest level completed: td Furthest level completed:
td TODO td= instanceStats.furthestLevelCompleted.replace('Course: ', '')
mixin progress-summary-concepts mixin progress-summary-concepts
h3 Concepts Covered h3 Concepts Covered
@ -141,9 +165,9 @@ mixin progress-members
mixin progress-members-individual(memberID) mixin progress-members-individual(memberID)
- var name = memberUserMap[memberID] ? memberUserMap[memberID].get('name') : 'Anoner' - var name = memberUserMap[memberID] ? memberUserMap[memberID].get('name') : 'Anoner'
a(href="/user/#{memberID}")= name || 'Anoner' a(href="/user/#{memberID}")= name || 'Anoner'
div TODO: levels completed if memberStats && memberStats[memberID]
div TODO: total time played div #{memberStats[memberID].totalLevelsCompleted} levels
div TODO: last played div Played #{moment.duration(memberStats[memberID].totalPlayTime, "seconds").humanize()}
mixin progress-members-concepts(memberID) mixin progress-members-concepts(memberID)
if course && userLevelStateMap[memberID] if course && userLevelStateMap[memberID]
@ -160,11 +184,11 @@ mixin progress-members-levels-expanded(memberID)
- var i = 0 - var i = 0
each level, levelID in campaign.get('levels') each level, levelID in campaign.get('levels')
if userLevelStateMap[memberID][levelID] === 'complete' if userLevelStateMap[memberID][levelID] === 'complete'
span.progress-level-cell.progress-level-cell-complete #{i + 1} span.progress-level-cell.progress-level-cell-complete(data-level-id=levelID, data-level-slug=level.slug, data-user-id=memberID) #{i + 1}
span.spl= level.name.replace('Course: ', '') span.spl= level.name.replace('Course: ', '')
+progress-members-popup-completed(i, level) +progress-members-popup-completed(i, level)
else if userLevelStateMap[memberID][levelID] === 'started' else if userLevelStateMap[memberID][levelID] === 'started'
span.progress-level-cell.progress-level-cell-started #{i + 1} #{level.name.replace('Course: ', '')} span.progress-level-cell.progress-level-cell-started(data-level-id=levelID, data-level-slug=level.slug, data-user-id=memberID) #{i + 1} #{level.name.replace('Course: ', '')}
+progress-members-popup-started(i, level) +progress-members-popup-started(i, level)
else else
span.progress-level-cell #{i + 1} #{level.name.replace('Course: ', '')} span.progress-level-cell #{i + 1} #{level.name.replace('Course: ', '')}
@ -179,10 +203,10 @@ mixin progress-members-levels-condensed(memberID)
- var i = 0 - var i = 0
each level, levelID in campaign.get('levels') each level, levelID in campaign.get('levels')
if userLevelStateMap[memberID][levelID] === 'complete' if userLevelStateMap[memberID][levelID] === 'complete'
span.progress-level-cell.progress-level-cell-complete(style="width:#{levelCellWidth}%;") #{i + 1} span.progress-level-cell.progress-level-cell-complete(style="width:#{levelCellWidth}%;", data-level-id=levelID, data-level-slug=level.slug, data-user-id=memberID) #{i + 1}
+progress-members-popup-completed(i, level) +progress-members-popup-completed(i, level)
else if userLevelStateMap[memberID][levelID] === 'started' else if userLevelStateMap[memberID][levelID] === 'started'
span.progress-level-cell.progress-level-cell-started(style="width:#{levelCellWidth}%;") #{i + 1} span.progress-level-cell.progress-level-cell-started(style="width:#{levelCellWidth}%;", data-level-id=levelID, data-level-slug=level.slug, data-user-id=memberID) #{i + 1}
+progress-members-popup-started(i, level) +progress-members-popup-started(i, level)
else else
break break
@ -191,14 +215,17 @@ mixin progress-members-levels-condensed(memberID)
mixin progress-members-popup-completed(i, level) mixin progress-members-popup-completed(i, level)
.progress-popup-container .progress-popup-container
h3 #{i + 1}. #{level.name.replace('Course: ', '')} h3 #{i + 1}. #{level.name.replace('Course: ', '')}
p TODO: Time to solve p Play time: #{moment.duration(level.playtime, "seconds").humanize()}
p TODO: Completed on p Completed: #{moment(level.changed).format('MMMM Do YYYY, h:mm:ss a')}
if adminMode
strong Click to view solution. strong Click to view solution.
mixin progress-members-popup-started(i, level) mixin progress-members-popup-started(i, level)
.progress-popup-container .progress-popup-container
h3 #{i + 1}. #{level.name.replace('Course: ', '')} h3 #{i + 1}. #{level.name.replace('Course: ', '')}
p TODO: last played on p Play time: #{moment.duration(level.playtime, "seconds").humanize()}
p Last played: #{moment(level.changed).format('MMMM Do YYYY, h:mm:ss a')}
if adminMode
strong Click to view solution. strong Click to view solution.
mixin invite-tab mixin invite-tab

View file

@ -2,8 +2,6 @@ extends /templates/base
block content block content
//- DO NOT localize / i18n
div div
span *UNDER CONSTRUCTION, send feedback to span *UNDER CONSTRUCTION, send feedback to
a.spl(href='mailto:team@codecombat.com') team@codecombat.com a.spl(href='mailto:team@codecombat.com') team@codecombat.com

View file

@ -2,8 +2,6 @@ extends /templates/base
block content block content
//- DO NOT localize / i18n
div(style='border-bottom: 1px solid black') div(style='border-bottom: 1px solid black')
span *UNDER CONSTRUCTION, please send feedback to span *UNDER CONSTRUCTION, please send feedback to
a.spl(href='mailto:team@codecombat.com') team@codecombat.com a.spl(href='mailto:team@codecombat.com') team@codecombat.com
@ -92,8 +90,9 @@ mixin teacher-dialog(course)
.container-fluid .container-fluid
.row .row
.col-md-8 .col-md-8
select.form-control.select-session select.form-control.select-session(data-course-id="#{course.id}")
each inst in instances each inst in instances
if inst.get('courseID') == course.id
if inst.get('name') if inst.get('name')
option(value="#{inst.id}")= inst.get('name') option(value="#{inst.id}")= inst.get('name')
else else

View file

@ -0,0 +1,27 @@
ModalView = require 'views/core/ModalView'
template = require 'templates/account/prepaid-redeem-modal'
{me} = require 'core/auth'
module.exports = class PrepaidRedeemModal extends ModalView
id: 'prepaid-redeem-modal'
template: template
closeButton: true
events:
'click #redeem' : 'onRedeemClicked'
constructor: (options) ->
super options
@ppc = options.ppc
hasRedeemed = @ppc.userHasRedeemed(me.get('_id'))
@redeemedOn = new moment(hasRedeemed).calendar() if hasRedeemed
getRenderData: ->
c = super()
c.ppc = @ppc
c.redeemedOn = @redeemedOn if @redeemedOn
c
onRedeemClicked: ->
@trigger 'confirm-redeem'

View file

@ -0,0 +1,238 @@
RootView = require 'views/core/RootView'
template = require 'templates/account/prepaid-view'
stripeHandler = require 'core/services/stripe'
{getPrepaidCodeAmount} = require '../../core/utils'
CocoCollection = require 'collections/CocoCollection'
Prepaid = require '../../models/Prepaid'
utils = require 'core/utils'
RedeemModal = require 'views/account/PrepaidRedeemModal'
forms = require 'core/forms'
# TODO: remove redeem code modal
module.exports = class PrepaidView extends RootView
id: 'prepaid-view'
template: template
className: 'container-fluid'
events:
'change #users': 'onUsersChanged'
'change #months': 'onMonthsChanged'
'click #purchase-button': 'onPurchaseClicked'
'click #redeem-button': 'onRedeemClicked'
'click .btn-check-code': 'onClickCheckCode'
'click .btn-redeem-code': 'onClickRedeemCode'
subscriptions:
'stripe:received-token': 'onStripeReceivedToken'
baseAmount: 9.99
constructor: (options) ->
super(options)
@purchase =
total: @baseAmount
users: 3
months: 3
@updateTotal()
@codes = new CocoCollection([], { url: '/db/user/'+me.id+'/prepaid_codes', model: Prepaid })
@codes.on 'add', (code) =>
@render?()
@codes.on 'sync', (code) =>
@render?()
@supermodel.loadCollection(@codes, 'prepaid', {cache: false})
@ppc = utils.getQueryVariable('_ppc') ? ''
unless _.isEmpty(@ppc)
@ppcQuery = true
@loadPrepaid(@ppc)
getRenderData: ->
c = super()
c.purchase = @purchase
c.codes = @codes
c.ppc = @ppc
c.ppcInfo = @ppcInfo ? []
c.ppcQuery = @ppcQuery ? false
c
afterRender: ->
super()
@$el.find("span[title]").tooltip()
statusMessage: (message, type='alert') ->
noty text: message, layout: 'topCenter', type: type, killer: false, timeout: 5000, dismissQueue: true, maxVisible: 3
updateTotal: ->
@purchase.total = getPrepaidCodeAmount(@baseAmount, @purchase.users, @purchase.months)
@renderSelectors("#total", "#users", "#months")
# Form Input Callbacks
onUsersChanged: (e) ->
newAmount = $(e.target).val()
newAmount = 1 if newAmount < 1
@purchase.users = newAmount
el = $('#purchasepanel')
if newAmount < 3 and @purchase.months < 3
message = "Either Users or Months must be greater than 2"
err = [message: message, property: 'users', formatted: true]
forms.clearFormAlerts(el)
forms.applyErrorsToForm(el, err)
else
forms.clearFormAlerts(el)
@updateTotal()
onMonthsChanged: (e) ->
newAmount = $(e.target).val()
newAmount = 1 if newAmount < 1
@purchase.months = newAmount
el = $('#purchasepanel')
if newAmount < 3 and @purchase.users < 3
message = "Either Users or Months must be greater than 2"
err = [message: message, property: 'months', formatted: true]
forms.clearFormAlerts(el)
forms.applyErrorsToForm(el, err)
else
forms.clearFormAlerts(el)
@updateTotal()
onPurchaseClicked: (e) ->
return unless $("#users").val() >= 3 or $("#months").val() >= 3
@purchaseTimestamp = new Date().getTime()
@stripeAmount = @purchase.total * 100
@description = "Prepaid Code for " + @purchase.users + " users / " + @purchase.months + " months"
stripeHandler.open
amount: @stripeAmount
description: @description
bitcoin: true
alipay: if me.get('chinaVersion') or (me.get('preferredLanguage') or 'en-US')[...2] is 'zh' then true else 'auto'
onRedeemClicked: (e) ->
@ppc = $('#ppc').val()
unless @ppc
@statusMessage "You must enter a code.", "error"
return
options =
url: '/db/prepaid/-/code/'+ @ppc
method: 'GET'
options.success = (model, res, options) =>
redeemModal = new RedeemModal ppc: model
redeemModal.on 'confirm-redeem', @confirmRedeem
@openModalView redeemModal
options.error = (model, res, options) =>
console.warn 'Error getting Prepaid Code'
prepaid = new Prepaid()
prepaid.fetch(options)
# @supermodel.addRequestResource('get_prepaid', options, 0).load()
confirmRedeem: =>
options =
url: '/db/subscription/-/subscribe_prepaid'
method: 'POST'
data: { ppc: @ppc }
options.error = (model, res, options, foo) =>
# console.error 'FAILED redeeming prepaid code'
msg = model.responseText ? ''
@statusMessage "Error: Could not redeem prepaid code. #{msg}", "error"
options.success = (model, res, options) =>
# console.log 'SUCCESS redeeming prepaid code'
@statusMessage "Prepaid Code Redeemed!", "success"
@supermodel.loadCollection(@codes, 'prepaid', {cache: false})
@codes.fetch()
me.fetch cache: false
@supermodel.addRequestResource('subscribe_prepaid', options, 0).load()
onStripeReceivedToken: (e) ->
# TODO: show that something is happening in the UI
options =
url: '/db/prepaid/-/purchase'
method: 'POST'
options.data =
amount: @stripeAmount
description: @description
stripe:
token: e.token.id
timestamp: @purchaseTimestamp
type: 'terminal_subscription'
maxRedeemers: @purchase.users
months: @purchase.months
options.error = (model, response, options) =>
console.error 'FAILED: Prepaid purchase', response
console.error options
@statusMessage "Error purchasing prepaid code", "error"
# Not sure when this will happen. Stripe popup seems to give appropriate error messages.
options.success = (model, response, options) =>
# console.log 'SUCCESS: Prepaid purchase', model.code
@statusMessage "Successfully purchased Prepaid Code!", "success"
@codes.add(model)
@statusMessage "Finalizing purchase...", "information"
@supermodel.addRequestResource('purchase_prepaid', options, 0).load()
loadPrepaid: (ppc) ->
return unless ppc
options =
cache: false
method: 'GET'
url: "/db/prepaid/-/code/#{ppc}"
options.success = (model, res, options) =>
@ppcInfo = []
if model.get('type') is 'terminal_subscription'
months = model.get('properties')?.months ? 0
maxRedeemers = model.get('maxRedeemers') ? 0
redeemers = model.get('redeemers') ? []
unlocksLeft = maxRedeemers - redeemers.length
@ppcInfo.push "This prepaid code adds <strong>#{months} months of subscription</strong> to your account."
@ppcInfo.push "It can be used <strong>#{unlocksLeft} more</strong> times."
# TODO: user needs to know they can't apply it more than once to their account
else
@ppcInfo.push "Type: #{model.get('type')}"
@render?()
options.error = (model, res, options) =>
@statusMessage "Unable to retrieve code.", "error"
@prepaid = new Prepaid()
@prepaid.fetch(options)
onClickCheckCode: (e) ->
@ppc = $('.input-ppc').val()
unless @ppc
@statusMessage "You must enter a code.", "error"
return
@ppcInfo = []
@render?()
@loadPrepaid(@ppc)
onClickRedeemCode: (e) ->
@ppc = $('.input-ppc').val()
options =
url: '/db/subscription/-/subscribe_prepaid'
method: 'POST'
data: { ppc: @ppc }
options.error = (model, res, options, foo) =>
msg = model.responseText ? ''
@statusMessage "Error: Could not redeem prepaid code. #{msg}", "error"
options.success = (model, res, options) =>
@statusMessage "Prepaid applied to your account!", "success"
@codes.fetch cache: false
me.fetch cache: false
@loadPrepaid(@ppc)
@supermodel.addRequestResource('subscribe_prepaid', options, 0).load()

View file

@ -15,6 +15,7 @@ module.exports = class MainAdminView extends RootView
'click #increment-button': 'incrementUserAttribute' 'click #increment-button': 'incrementUserAttribute'
'click #user-search-result': 'onClickUserSearchResult' 'click #user-search-result': 'onClickUserSearchResult'
'click #create-free-sub-btn': 'onClickFreeSubLink' 'click #create-free-sub-btn': 'onClickFreeSubLink'
'click #terminal-create': 'onClickTerminalSubLink'
getRenderData: -> getRenderData: ->
context = super() context = super()
@ -89,3 +90,27 @@ module.exports = class MainAdminView extends RootView
options.error = (model, response, options) => options.error = (model, response, options) =>
console.error 'Failed to create prepaid', response console.error 'Failed to create prepaid', response
@supermodel.addRequestResource('create_prepaid', options, 0).load() @supermodel.addRequestResource('create_prepaid', options, 0).load()
onClickTerminalSubLink: (e) =>
@freeSubLink = ''
return unless me.isAdmin()
options =
url: '/db/prepaid/-/create'
method: 'POST'
data:
type: 'terminal_subscription'
maxRedeemers: parseInt($("#users").val())
months: parseInt($("#months").val())
options.success = (model, response, options) =>
# TODO: Don't hardcode domain.
if application.isProduction()
@freeSubLink = "https://codecombat.com/account/prepaid?_ppc=#{model.code}"
else
@freeSubLink = "http://localhost:3000/account/prepaid?_ppc=#{model.code}"
@render?()
options.error = (model, response, options) =>
console.error 'Failed to create prepaid', response
@supermodel.addRequestResource('create_prepaid', options, 0).load()

View file

@ -48,7 +48,6 @@ module.exports = class SubscribeModal extends ModalView
popoverTitle = $.i18n.t 'subscribe.parent_email_title' popoverTitle = $.i18n.t 'subscribe.parent_email_title'
popoverTitle += '<button type="button" class="close" onclick="$(&#39;.parent-button&#39;).popover(&#39;hide&#39;);">&times;</button>' popoverTitle += '<button type="button" class="close" onclick="$(&#39;.parent-button&#39;).popover(&#39;hide&#39;);">&times;</button>'
popoverContent = -> popoverContent = ->
console.log 'found html', $('.parent-button-popover-content').html()
$('.parent-button-popover-content').html() $('.parent-button-popover-content').html()
@$el.find('.parent-button').popover( @$el.find('.parent-button').popover(
animation: true animation: true

View file

@ -8,10 +8,6 @@ template = require 'templates/courses/course-details'
User = require 'models/User' User = require 'models/User'
utils = require 'core/utils' utils = require 'core/utils'
# TODO: logged out experience
# TODO: no course instances
# TODO: no course instance selected
module.exports = class CourseDetailsView extends RootView module.exports = class CourseDetailsView extends RootView
id: 'course-details-view' id: 'course-details-view'
template: template template: template
@ -20,20 +16,25 @@ module.exports = class CourseDetailsView extends RootView
'change .progress-expand-checkbox': 'onCheckExpandedProgress' 'change .progress-expand-checkbox': 'onCheckExpandedProgress'
'click .btn-play-level': 'onClickPlayLevel' 'click .btn-play-level': 'onClickPlayLevel'
'click .btn-save-settings': 'onClickSaveSettings' 'click .btn-save-settings': 'onClickSaveSettings'
'click .btn-select-instance': 'onClickSelectInstance'
'click .progress-member-header': 'onClickMemberHeader' 'click .progress-member-header': 'onClickMemberHeader'
'click .progress-header': 'onClickProgressHeader' 'click .progress-header': 'onClickProgressHeader'
'click .progress-level-cell': 'onClickProgressLevelCell'
'mouseenter .progress-level-cell': 'onMouseEnterPoint' 'mouseenter .progress-level-cell': 'onMouseEnterPoint'
'mouseleave .progress-level-cell': 'onMouseLeavePoint' 'mouseleave .progress-level-cell': 'onMouseLeavePoint'
constructor: (options, @courseID) -> constructor: (options, @courseID, @courseInstanceID) ->
super options super options
@courseInstanceID = utils.getQueryVariable('ciid', false) or options.courseInstanceID @courseID ?= options.courseID
@courseInstanceID ?= options.courseInstanceID
@adminMode = me.isAdmin() @adminMode = me.isAdmin()
@memberSort = 'nameAsc' @memberSort = 'nameAsc'
unless me.isAnonymous() @course = @supermodel.getModel(Course, @courseID) or new Course _id: @courseID
@course = new Course _id: @courseID
@listenTo @course, 'sync', @onCourseSync @listenTo @course, 'sync', @onCourseSync
@supermodel.loadModel @course, 'course', cache: false if @course.loaded
@onCourseSync()
else
@supermodel.loadModel @course, 'course'
getRenderData: -> getRenderData: ->
context = super() context = super()
@ -42,9 +43,14 @@ module.exports = class CourseDetailsView extends RootView
context.conceptsCompleted = @conceptsCompleted ? {} context.conceptsCompleted = @conceptsCompleted ? {}
context.course = @course if @course?.loaded context.course = @course if @course?.loaded
context.courseInstance = @courseInstance if @courseInstance?.loaded context.courseInstance = @courseInstance if @courseInstance?.loaded
context.courseInstances = @courseInstances?.models ? []
context.instanceStats = @instanceStats
context.levelConceptMap = @levelConceptMap ? {} context.levelConceptMap = @levelConceptMap ? {}
context.memberSort = @memberSort context.memberSort = @memberSort
context.memberStats = @memberStats
context.memberUserMap = @memberUserMap ? {} context.memberUserMap = @memberUserMap ? {}
context.noCourseInstance = @noCourseInstance
context.noCourseInstanceSelected = @noCourseInstanceSelected
context.showExpandedProgress = @showExpandedProgress context.showExpandedProgress = @showExpandedProgress
context.sortedMembers = @sortedMembers ? [] context.sortedMembers = @sortedMembers ? []
context.userConceptStateMap = @userConceptStateMap ? {} context.userConceptStateMap = @userConceptStateMap ? {}
@ -53,10 +59,18 @@ module.exports = class CourseDetailsView extends RootView
onCourseSync: -> onCourseSync: ->
# console.log 'onCourseSync' # console.log 'onCourseSync'
if me.isAnonymous()
@noCourseInstance = true
@render?()
return
return if @campaign? return if @campaign?
@campaign = new Campaign _id: @course.get('campaignID') campaignID = @course.get('campaignID')
@campaign = @supermodel.getModel(Campaign, campaignID) or new Campaign _id: campaignID
@listenTo @campaign, 'sync', @onCampaignSync @listenTo @campaign, 'sync', @onCampaignSync
@supermodel.loadModel @campaign, 'campaign', cache: false if @campaign.loaded
@onCampaignSync()
else
@supermodel.loadModel @campaign, 'campaign'
@render?() @render?()
onCampaignSync: -> onCampaignSync: ->
@ -77,19 +91,27 @@ module.exports = class CourseDetailsView extends RootView
loadCourseInstance: (courseInstanceID) -> loadCourseInstance: (courseInstanceID) ->
# console.log 'loadCourseInstance' # console.log 'loadCourseInstance'
return if @courseInstance? return if @courseInstance?
@courseInstance = new CourseInstance _id: courseInstanceID @courseInstanceID = courseInstanceID
@courseInstance = @supermodel.getModel(CourseInstance, @courseInstanceID) or new CourseInstance _id: @courseInstanceID
@listenTo @courseInstance, 'sync', @onCourseInstanceSync @listenTo @courseInstance, 'sync', @onCourseInstanceSync
@supermodel.loadModel @courseInstance, 'course_instance', cache: false if @courseInstance.loaded
@onCourseInstanceSync()
else
@courseInstance = @supermodel.loadModel(@courseInstance, 'course_instance').model
onCourseInstancesSync: -> onCourseInstancesSync: ->
# console.log 'onCourseInstancesSync' # console.log 'onCourseInstancesSync'
if @courseInstances.models.length is 1 if @courseInstances.models.length is 1
@loadCourseInstance(@courseInstances.models[0].id) @loadCourseInstance(@courseInstances.models[0].id)
else if @courseInstances.models.length > 0 else
@loadCourseInstance(@courseInstances.models[0].id) if @courseInstances.models.length is 0
@noCourseInstance = true
else
@noCourseInstanceSelected = true
@render?()
onCourseInstanceSync: -> onCourseInstanceSync: ->
console.log 'onCourseInstanceSync', @courseInstance.get('description') # console.log 'onCourseInstanceSync'
@adminMode = true if @courseInstance.get('ownerID') is me.id @adminMode = true if @courseInstance.get('ownerID') is me.id
@levelSessions = new CocoCollection([], { url: "/db/course_instance/#{@courseInstance.id}/level_sessions", model: LevelSession, comparator:'_id' }) @levelSessions = new CocoCollection([], { url: "/db/course_instance/#{@courseInstance.id}/level_sessions", model: LevelSession, comparator:'_id' })
@listenToOnce @levelSessions, 'sync', @onLevelSessionsSync @listenToOnce @levelSessions, 'sync', @onLevelSessionsSync
@ -101,17 +123,40 @@ module.exports = class CourseDetailsView extends RootView
onLevelSessionsSync: -> onLevelSessionsSync: ->
# console.log 'onLevelSessionsSync' # console.log 'onLevelSessionsSync'
@instanceStats = averageLevelsCompleted: 0, furthestLevelCompleted: '', totalLevelsCompleted: 0, totalPlayTime: 0
@memberStats = {}
@userConceptStateMap = {} @userConceptStateMap = {}
@userLevelSessionMap = {}
@userLevelStateMap = {} @userLevelStateMap = {}
levelStateMap = {}
for levelSession in @levelSessions.models for levelSession in @levelSessions.models
userID = levelSession.get('creator') userID = levelSession.get('creator')
levelID = levelSession.get('level').original levelID = levelSession.get('level').original
@userConceptStateMap[userID] ?= {}
@userLevelStateMap[userID] ?= {}
state = if levelSession.get('state')?.complete then 'complete' else 'started' state = if levelSession.get('state')?.complete then 'complete' else 'started'
@userLevelStateMap[userID][levelID] = state levelStateMap[levelID] = state
@instanceStats.totalLevelsCompleted++ if state is 'complete'
@instanceStats.totalPlayTime += levelSession.get('playtime')
@memberStats[userID] ?= totalLevelsCompleted: 0, totalPlayTime: 0
@memberStats[userID].totalLevelsCompleted++ if state is 'complete'
@memberStats[userID].totalPlayTime += levelSession.get('playtime')
@userConceptStateMap[userID] ?= {}
for concept of @levelConceptMap[levelID] for concept of @levelConceptMap[levelID]
@userConceptStateMap[userID][concept] = state @userConceptStateMap[userID][concept] = state
@userLevelSessionMap[userID] ?= {}
@userLevelSessionMap[userID][levelID] = levelSession
@userLevelStateMap[userID] ?= {}
@userLevelStateMap[userID][levelID] = state
if @courseInstance.get('members').length > 0
@instanceStats.averageLevelsCompleted = @instanceStats.totalLevelsCompleted / @courseInstance.get('members').length
for levelID, level of @campaign.get('levels')
@instanceStats.furthestLevelCompleted = level.name if levelStateMap[levelID] is 'complete'
@conceptsCompleted = {} @conceptsCompleted = {}
for userID, conceptStateMap of @userConceptStateMap for userID, conceptStateMap of @userConceptStateMap
for concept, state of conceptStateMap for concept, state of conceptStateMap
@ -148,7 +193,7 @@ module.exports = class CourseDetailsView extends RootView
Backbone.Mediator.publish 'router:navigate', { Backbone.Mediator.publish 'router:navigate', {
route: "/play/level/#{levelSlug}" route: "/play/level/#{levelSlug}"
viewClass: 'views/play/level/PlayLevelView' viewClass: 'views/play/level/PlayLevelView'
viewArgs: [{}, levelSlug] viewArgs: [{courseID: @courseID, courseInstanceID: @courseInstanceID}, levelSlug]
} }
onClickSaveSettings: (e) -> onClickSaveSettings: (e) ->
@ -161,9 +206,29 @@ module.exports = class CourseDetailsView extends RootView
@courseInstance.patch() @courseInstance.patch()
$('#settingsModal').modal('hide') $('#settingsModal').modal('hide')
onClickSelectInstance: (e) ->
courseInstanceID = $('.select-instance').val()
@noCourseInstanceSelected = false
@loadCourseInstance(courseInstanceID)
onClickProgressLevelCell: (e) ->
return unless @adminMode
levelID = $(e.currentTarget).data('level-id')
levelSlug = $(e.currentTarget).data('level-slug')
userID = $(e.currentTarget).data('user-id')
return unless levelID and levelSlug and userID
route = "/play/level/#{levelSlug}"
if @userLevelSessionMap[userID]?[levelID]
route += "?session=#{@userLevelSessionMap[userID][levelID].id}&observing=true"
Backbone.Mediator.publish 'router:navigate', {
route: route
viewClass: 'views/play/level/PlayLevelView'
viewArgs: [{}, levelSlug]
}
onMouseEnterPoint: (e) -> onMouseEnterPoint: (e) ->
$('.level-popup-container').hide() $('.progress-popup-container').hide()
container = $(e.target).find('.level-popup-container').show() container = $(e.target).find('.progress-popup-container').show()
margin = 20 margin = 20
offset = $(e.target).offset() offset = $(e.target).offset()
scrollTop = $('#page-container').scrollTop() scrollTop = $('#page-container').scrollTop()
@ -172,7 +237,7 @@ module.exports = class CourseDetailsView extends RootView
container.css('top', offset.top + scrollTop - height - margin) container.css('top', offset.top + scrollTop - height - margin)
onMouseLeavePoint: (e) -> onMouseLeavePoint: (e) ->
$(e.target).find('.level-popup-container').hide() $(e.target).find('.progress-popup-container').hide()
sortMembers: -> sortMembers: ->
# Progress sort precedence: most completed concepts, most started concepts, most levels, name sort # Progress sort precedence: most completed concepts, most started concepts, most levels, name sort

View file

@ -74,10 +74,11 @@ module.exports = class CoursesView extends RootView
onClickEnter: (e) -> onClickEnter: (e) ->
$('.continue-dialog').modal('hide') $('.continue-dialog').modal('hide')
courseID = $(e.target).data('course-id') courseID = $(e.target).data('course-id')
courseInstanceID = $('.select-session').val() courseInstanceID = $(".select-session[data-course-id=#{courseID}]").val()
route = "/courses/#{courseID}/#{courseInstanceID}"
viewClass = require 'views/courses/CourseDetailsView' viewClass = require 'views/courses/CourseDetailsView'
viewArgs = [{courseInstanceID:courseInstanceID}, courseID] viewArgs = [{}, courseID, courseInstanceID]
navigationEvent = route: "/courses/#{courseID}", viewClass: viewClass, viewArgs: viewArgs navigationEvent = route: route, viewClass: viewClass, viewArgs: viewArgs
Backbone.Mediator.publish 'router:navigate', navigationEvent Backbone.Mediator.publish 'router:navigate', navigationEvent
onClickStudent: (e) -> onClickStudent: (e) ->

View file

@ -76,7 +76,7 @@ module.exports = class MyMatchesTabView extends CocoView
state = 'tie' if match.metrics.rank is opponent.metrics.rank state = 'tie' if match.metrics.rank is opponent.metrics.rank
fresh = match.date > (new Date(new Date() - 20 * 1000)).toISOString() fresh = match.date > (new Date(new Date() - 20 * 1000)).toISOString()
if fresh if fresh
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'chat_received' @playSound 'chat_received'
{ {
state: state state: state
opponentName: @nameMap[opponent.userID] opponentName: @nameMap[opponent.userID]
@ -86,7 +86,7 @@ module.exports = class MyMatchesTabView extends CocoView
stale: match.date < submitDate stale: match.date < submitDate
fresh: fresh fresh: fresh
codeLanguage: match.codeLanguage codeLanguage: match.codeLanguage
simulator: JSON.stringify(match.simulator) + ' | seed ' + match.randomSeed simulator: if match.simulator then JSON.stringify(match.simulator) + ' | seed ' + match.randomSeed else ''
} }
for team in @teams for team in @teams
@ -105,7 +105,7 @@ module.exports = class MyMatchesTabView extends CocoView
team.scoreHistory = scoreHistory team.scoreHistory = scoreHistory
if not team.isRanking and @previouslyRankingTeams[team.id] if not team.isRanking and @previouslyRankingTeams[team.id]
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'cast-end' @playSound 'cast-end'
@previouslyRankingTeams[team.id] = team.isRanking @previouslyRankingTeams[team.id] = team.isRanking
ctx ctx

View file

@ -28,6 +28,9 @@ module.exports = class ControlBarView extends CocoView
'click #control-bar-sign-up-button': 'onClickSignupButton' 'click #control-bar-sign-up-button': 'onClickSignupButton'
constructor: (options) -> constructor: (options) ->
@courseID = options.courseID
@courseInstanceID = options.courseInstanceID
@worldName = options.worldName @worldName = options.worldName
@session = options.session @session = options.session
@level = options.level @level = options.level
@ -88,13 +91,15 @@ module.exports = class ControlBarView extends CocoView
@homeLink += '/' + campaign @homeLink += '/' + campaign
@homeViewArgs.push campaign @homeViewArgs.push campaign
else if @level.get('type', true) in ['course'] else if @level.get('type', true) in ['course']
@homeLink = '/courses/mock1' @homeLink = '/courses'
@homeViewClass = 'views/courses/mock1/CourseDetailsView' @homeViewClass = 'views/courses/CoursesView'
#campaign = @level.get 'campaign' if @courseID
#@homeLink += '/' + campaign @homeLink += "/#{@courseID}"
#@homeViewArgs.push campaign @homeViewArgs.push @courseID
@homeLink += '/' + '0' @homeViewClass = 'views/courses/CourseDetailsView'
@homeViewArgs.push '0' if @courseInstanceID
@homeLink += "/#{@courseInstanceID}"
@homeViewArgs.push @courseInstanceID
else else
@homeLink = '/' @homeLink = '/'
@homeViewClass = 'views/HomeView' @homeViewClass = 'views/HomeView'

View file

@ -54,7 +54,7 @@ module.exports = class LevelChatView extends CocoView
@playNoise() if e.message.authorID isnt me.id @playNoise() if e.message.authorID isnt me.id
playNoise: -> playNoise: ->
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'chat_received' @playSound 'chat_received'
messageObjectToJQuery: (message) -> messageObjectToJQuery: (message) ->
td = $('<td></td>') td = $('<td></td>')

View file

@ -97,7 +97,7 @@ module.exports = class LevelGoalsView extends CocoView
@lastSizeTweenTime = new Date() @lastSizeTweenTime = new Date()
@updatePlacement() @updatePlacement()
if @soundToPlayWhenPlaybackEnded if @soundToPlayWhenPlaybackEnded
Backbone.Mediator.publish 'audio-player:play-sound', trigger: @soundToPlayWhenPlaybackEnded, volume: 1 @playSound @soundToPlayWhenPlaybackEnded
updateHeight: -> updateHeight: ->
return if @$el.hasClass('brighter') or @$el.hasClass('secret') return if @$el.hasClass('brighter') or @$el.hasClass('secret')
@ -122,7 +122,7 @@ module.exports = class LevelGoalsView extends CocoView
playToggleSound: (sound) => playToggleSound: (sound) =>
return if @destroyed return if @destroyed
Backbone.Mediator.publish 'audio-player:play-sound', trigger: sound, volume: 1 @playSound sound
@soundTimeout = null @soundTimeout = null
onSetLetterbox: (e) -> onSetLetterbox: (e) ->

View file

@ -71,7 +71,7 @@ module.exports = class LevelLoadingView extends CocoView
@startUnveiling() @startUnveiling()
@unveil() @unveil()
else else
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'level_loaded', volume: 0.75 # old: loading_ready @playSound 'level_loaded', 0.75 # old: loading_ready
@$el.find('.progress').hide() @$el.find('.progress').hide()
@$el.find('.start-level-button').show() @$el.find('.start-level-button').show()
@ -97,7 +97,7 @@ module.exports = class LevelLoadingView extends CocoView
loadingDetails.css 'top', -loadingDetails.outerHeight(true) loadingDetails.css 'top', -loadingDetails.outerHeight(true)
@$el.find('.left-wing').css left: '-100%', backgroundPosition: 'right -400px top 0' @$el.find('.left-wing').css left: '-100%', backgroundPosition: 'right -400px top 0'
@$el.find('.right-wing').css right: '-100%', backgroundPosition: 'left -400px top 0' @$el.find('.right-wing').css right: '-100%', backgroundPosition: 'left -400px top 0'
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'loading-view-unveil', volume: 0.5 @playSound 'loading-view-unveil', 0.5
_.delay @onUnveilEnded, duration * 1000 _.delay @onUnveilEnded, duration * 1000
$('#level-footer-background').detach().appendTo('#page-container').slideDown(duration * 1000) $('#level-footer-background').detach().appendTo('#page-container').slideDown(duration * 1000)

View file

@ -108,7 +108,7 @@ module.exports = class LevelPlaybackView extends CocoView
@realTime = true @realTime = true
@togglePlaybackControls false @togglePlaybackControls false
Backbone.Mediator.publish 'playback:real-time-playback-started', {} Backbone.Mediator.publish 'playback:real-time-playback-started', {}
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'real-time-playback-start', volume: 1 @playSound 'real-time-playback-start'
onRealTimeMultiplayerCast: (e) -> onRealTimeMultiplayerCast: (e) ->
@realTime = true @realTime = true
@ -160,7 +160,7 @@ module.exports = class LevelPlaybackView extends CocoView
ended = button.hasClass 'ended' ended = button.hasClass 'ended'
changed = button.hasClass('playing') isnt @playing changed = button.hasClass('playing') isnt @playing
button.toggleClass('playing', @playing and not ended).toggleClass('paused', not @playing and not ended) button.toggleClass('playing', @playing and not ended).toggleClass('paused', not @playing and not ended)
Backbone.Mediator.publish 'audio-player:play-sound', trigger: (if @playing then 'playback-play' else 'playback-pause'), volume: 1 @playSound (if @playing then 'playback-play' else 'playback-pause')
return # don't stripe the bar return # don't stripe the bar
bar = @$el.find '.scrubber .progress' bar = @$el.find '.scrubber .progress'
bar.toggleClass('progress-striped', @playing and not ended).toggleClass('active', @playing and not ended) bar.toggleClass('progress-striped', @playing and not ended).toggleClass('active', @playing and not ended)
@ -266,7 +266,7 @@ module.exports = class LevelPlaybackView extends CocoView
return unless @realTime return unless @realTime
@realTime = false @realTime = false
@togglePlaybackControls true @togglePlaybackControls true
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'real-time-playback-end', volume: 1 @playSound 'real-time-playback-end'
onStopRealTimePlayback: (e) -> onStopRealTimePlayback: (e) ->
Backbone.Mediator.publish 'level:set-letterbox', on: false Backbone.Mediator.publish 'level:set-letterbox', on: false
@ -287,14 +287,14 @@ module.exports = class LevelPlaybackView extends CocoView
if ratioChange = @getScrubRatio() - oldRatio if ratioChange = @getScrubRatio() - oldRatio
sound = "playback-scrub-slide-#{if ratioChange > 0 then 'forward' else 'back'}-#{@slideCount % 3}" sound = "playback-scrub-slide-#{if ratioChange > 0 then 'forward' else 'back'}-#{@slideCount % 3}"
unless /back/.test sound # We don't have the back sounds in yet: http://discourse.codecombat.com/t/bug-some-mp3-lost/4830 unless /back/.test sound # We don't have the back sounds in yet: http://discourse.codecombat.com/t/bug-some-mp3-lost/4830
Backbone.Mediator.publish 'audio-player:play-sound', trigger: sound, volume: Math.min 1, Math.abs ratioChange * 50 @playSound sound, (Math.min 1, Math.abs ratioChange * 50)
start: (event, ui) => start: (event, ui) =>
return if @shouldIgnore() return if @shouldIgnore()
@slideCount = 0 @slideCount = 0
@wasPlaying = @playing and not $('#play-button').hasClass('ended') @wasPlaying = @playing and not $('#play-button').hasClass('ended')
Backbone.Mediator.publish 'level:set-playing', {playing: false} Backbone.Mediator.publish 'level:set-playing', {playing: false}
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'playback-scrub-start', volume: 0.5 @playSound 'playback-scrub-start', 0.5
stop: (event, ui) => stop: (event, ui) =>
@ -307,7 +307,7 @@ module.exports = class LevelPlaybackView extends CocoView
Backbone.Mediator.publish 'level:set-playing', {playing: false} Backbone.Mediator.publish 'level:set-playing', {playing: false}
@$el.find('.scrubber-handle').effect('bounce', {times: 2}) @$el.find('.scrubber-handle').effect('bounce', {times: 2})
else else
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'playback-scrub-end', volume: 0.5 @playSound 'playback-scrub-end', 0.5
) )

View file

@ -94,6 +94,9 @@ module.exports = class PlayLevelView extends RootView
console.profile?() if PROFILE_ME console.profile?() if PROFILE_ME
super options super options
@courseID = options.courseID
@courseInstanceID = options.courseInstanceID
@isEditorPreview = @getQueryVariable 'dev' @isEditorPreview = @getQueryVariable 'dev'
@sessionID = @getQueryVariable 'session' @sessionID = @getQueryVariable 'session'
@observing = @getQueryVariable 'observing' @observing = @getQueryVariable 'observing'
@ -248,7 +251,7 @@ module.exports = class PlayLevelView extends RootView
@insertSubView new ChatView levelID: @levelID, sessionID: @session.id, session: @session @insertSubView new ChatView levelID: @levelID, sessionID: @session.id, session: @session
@insertSubView new ProblemAlertView session: @session, level: @level, supermodel: @supermodel @insertSubView new ProblemAlertView session: @session, level: @level, supermodel: @supermodel
@insertSubView new DuelStatsView level: @level, session: @session, otherSession: @otherSession, supermodel: @supermodel, thangs: @world.thangs if @level.get('type') in ['hero-ladder', 'course-ladder'] @insertSubView new DuelStatsView level: @level, session: @session, otherSession: @otherSession, supermodel: @supermodel, thangs: @world.thangs if @level.get('type') in ['hero-ladder', 'course-ladder']
@insertSubView @controlBar = new ControlBarView {worldName: utils.i18n(@level.attributes, 'name'), session: @session, level: @level, supermodel: @supermodel} @insertSubView @controlBar = new ControlBarView {worldName: utils.i18n(@level.attributes, 'name'), session: @session, level: @level, supermodel: @supermodel, courseID: @courseID, courseInstanceID: @courseInstanceID}
#_.delay (=> Backbone.Mediator.publish('level:set-debug', debug: true)), 5000 if @isIPadApp() # if me.displayName() is 'Nick' #_.delay (=> Backbone.Mediator.publish('level:set-debug', debug: true)), 5000 if @isIPadApp() # if me.displayName() is 'Nick'
initVolume: -> initVolume: ->
@ -444,7 +447,7 @@ module.exports = class PlayLevelView extends RootView
showVictory: -> showVictory: ->
return if @level.hasLocalChanges() # Don't award achievements when beating level changed in level editor return if @level.hasLocalChanges() # Don't award achievements when beating level changed in level editor
@endHighlight() @endHighlight()
options = {level: @level, supermodel: @supermodel, session: @session, hasReceivedMemoryWarning: @hasReceivedMemoryWarning} options = {level: @level, supermodel: @supermodel, session: @session, hasReceivedMemoryWarning: @hasReceivedMemoryWarning, courseID: @courseID, courseInstanceID: @courseInstanceID}
ModalClass = if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder'] then HeroVictoryModal else VictoryModal ModalClass = if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder'] then HeroVictoryModal else VictoryModal
victoryModal = new ModalClass(options) victoryModal = new ModalClass(options)
@openModalView(victoryModal) @openModalView(victoryModal)

View file

@ -42,6 +42,9 @@ module.exports = class HeroVictoryModal extends ModalView
constructor: (options) -> constructor: (options) ->
super(options) super(options)
@courseID = options.courseID
@courseInstanceID = options.courseInstanceID
@session = options.session @session = options.session
@level = options.level @level = options.level
@thangTypes = {} @thangTypes = {}
@ -58,7 +61,7 @@ module.exports = class HeroVictoryModal extends ModalView
@previousLevel = me.level() @previousLevel = me.level()
else else
@readyToContinue = true @readyToContinue = true
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'victory' @playSound 'victory'
if @level.get('type', true) is 'course' and nextLevel = @level.get('nextLevel') if @level.get('type', true) is 'course' and nextLevel = @level.get('nextLevel')
@nextLevel = new Level().setURL "/db/level/#{nextLevel.original}/version/#{nextLevel.majorVersion}" @nextLevel = new Level().setURL "/db/level/#{nextLevel.original}/version/#{nextLevel.majorVersion}"
@nextLevel = @supermodel.loadModel(@nextLevel, 'level').model @nextLevel = @supermodel.loadModel(@nextLevel, 'level').model
@ -219,9 +222,10 @@ module.exports = class HeroVictoryModal extends ModalView
initializeAnimations: -> initializeAnimations: ->
if @level.get('type', true) is 'hero' if @level.get('type', true) is 'hero'
@updateXPBars 0 @updateXPBars 0
#playVictorySound = => @playSound 'victory-title-appear' # TODO: actually add this
@$el.find('#victory-header').delay(250).queue(-> @$el.find('#victory-header').delay(250).queue(->
$(@).removeClass('out').dequeue() $(@).removeClass('out').dequeue()
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'victory-title-appear' # TODO: actually add this #playVictorySound()
) )
complete = _.once(_.bind(@beginSequentialAnimations, @)) complete = _.once(_.bind(@beginSequentialAnimations, @))
@animatedPanels = $() @animatedPanels = $()
@ -284,7 +288,7 @@ module.exports = class HeroVictoryModal extends ModalView
@XPEl.text(totalXP) @XPEl.text(totalXP)
@updateXPBars(totalXP) @updateXPBars(totalXP)
xpTrigger = 'xp-' + (totalXP % 6) # 6 xp sounds xpTrigger = 'xp-' + (totalXP % 6) # 6 xp sounds
Backbone.Mediator.publish 'audio-player:play-sound', trigger: xpTrigger, volume: 0.5 + ratio / 2 @playSound xpTrigger, (0.5 + ratio / 2)
@XPEl.addClass 'four-digits' if totalXP >= 1000 and @lastTotalXP < 1000 @XPEl.addClass 'four-digits' if totalXP >= 1000 and @lastTotalXP < 1000
@XPEl.addClass 'five-digits' if totalXP >= 10000 and @lastTotalXP < 10000 @XPEl.addClass 'five-digits' if totalXP >= 10000 and @lastTotalXP < 10000
@lastTotalXP = totalXP @lastTotalXP = totalXP
@ -295,14 +299,14 @@ module.exports = class HeroVictoryModal extends ModalView
panel.textEl.text('+' + newGems) panel.textEl.text('+' + newGems)
@gemEl.text(totalGems) @gemEl.text(totalGems)
gemTrigger = 'gem-' + (parseInt(panel.number * ratio) % 4) # 4 gem sounds gemTrigger = 'gem-' + (parseInt(panel.number * ratio) % 4) # 4 gem sounds
Backbone.Mediator.publish 'audio-player:play-sound', trigger: gemTrigger, volume: 0.5 + ratio / 2 @playSound gemTrigger, (0.5 + ratio / 2)
@gemEl.addClass 'four-digits' if totalGems >= 1000 and @lastTotalGems < 1000 @gemEl.addClass 'four-digits' if totalGems >= 1000 and @lastTotalGems < 1000
@gemEl.addClass 'five-digits' if totalGems >= 10000 and @lastTotalGems < 10000 @gemEl.addClass 'five-digits' if totalGems >= 10000 and @lastTotalGems < 10000
@lastTotalGems = totalGems @lastTotalGems = totalGems
else if panel.item else if panel.item
thangType = @thangTypes[panel.item] thangType = @thangTypes[panel.item]
panel.textEl.text utils.i18n(thangType.attributes, 'name') panel.textEl.text utils.i18n(thangType.attributes, 'name')
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'item-unlocked', volume: 1 if 0.5 < ratio < 0.6 @playSound 'item-unlocked' if 0.5 < ratio < 0.6
else if panel.hero else if panel.hero
thangType = @thangTypes[panel.hero] thangType = @thangTypes[panel.hero]
panel.textEl.text(thangType.get('name')) panel.textEl.text(thangType.get('name'))
@ -399,10 +403,14 @@ module.exports = class HeroVictoryModal extends ModalView
if @level.get('type', true) is 'course' and nextLevel = @level.get('nextLevel') and not returnToCourse if @level.get('type', true) is 'course' and nextLevel = @level.get('nextLevel') and not returnToCourse
# need to do something more complicated to load its slug # need to do something more complicated to load its slug
console.log 'have @nextLevel', @nextLevel, 'from nextLevel', nextLevel console.log 'have @nextLevel', @nextLevel, 'from nextLevel', nextLevel
return "/play/level/#{@nextLevel.get('slug')}" link = "/play/level/#{@nextLevel.get('slug')}"
else if @level.get('type', true) is 'course' else if @level.get('type', true) is 'course'
# TODO: figure out which course it is link = "/courses"
return '/courses/mock1/0' if @courseID
link += "/#{@courseID}"
if @courseInstanceID
link += "/#{@courseInstanceID}"
else
link = '/play' link = '/play'
nextCampaign = @getNextLevelCampaign() nextCampaign = @getNextLevelCampaign()
link += '/' + nextCampaign link += '/' + nextCampaign
@ -418,11 +426,20 @@ module.exports = class HeroVictoryModal extends ModalView
_.merge options, extraOptions if extraOptions _.merge options, extraOptions if extraOptions
if @level.get('type', true) is 'course' and @nextLevel and not options.returnToCourse if @level.get('type', true) is 'course' and @nextLevel and not options.returnToCourse
viewClass = require 'views/play/level/PlayLevelView' viewClass = require 'views/play/level/PlayLevelView'
if @courseID
options.courseID = @courseID
if @courseInstanceID
options.courseInstanceID = @courseInstanceID
viewArgs = [options, @nextLevel.get('slug')] viewArgs = [options, @nextLevel.get('slug')]
else if @level.get('type', true) is 'course' else if @level.get('type', true) is 'course'
options.studentMode = true # TODO: shouldn't set viewClass and route in different places
viewClass = require 'views/courses/mock1/CourseDetailsView' viewClass = require 'views/courses/CoursesView'
viewArgs = [options, '0'] viewArgs = [options]
if @courseID
viewClass = require 'views/courses/CourseDetailsView'
viewArgs.push @courseID
if @courseInstanceID
viewArgs.push @courseInstanceID
else else
viewClass = require 'views/play/CampaignView' viewClass = require 'views/play/CampaignView'
viewArgs = [options, @getNextLevelCampaign()] viewArgs = [options, @getNextLevelCampaign()]

View file

@ -82,7 +82,7 @@ module.exports = class VictoryModal extends ModalView
afterInsert: -> afterInsert: ->
super() super()
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'victory' @playSound 'victory'
gapi?.plusone?.go? @$el[0] gapi?.plusone?.go? @$el[0]
FB?.XFBML?.parse? @$el[0] FB?.XFBML?.parse? @$el[0]
twttr?.widgets?.load?() twttr?.widgets?.load?()

View file

@ -59,7 +59,7 @@ module.exports = class ProblemAlertView extends CocoView
if @problem? if @problem?
@$el.addClass('alert').addClass("alert-#{@problem.aetherProblem.level}").hide().fadeIn('slow') @$el.addClass('alert').addClass("alert-#{@problem.aetherProblem.level}").hide().fadeIn('slow')
@$el.addClass('no-hint') unless @problem.aetherProblem.hint @$el.addClass('no-hint') unless @problem.aetherProblem.hint
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'error_appear', volume: 1.0 @playSound 'error_appear'
onShowProblemAlert: (data) -> onShowProblemAlert: (data) ->
return unless $('#code-area').is(":visible") return unless $('#code-area').is(":visible")
@ -80,7 +80,7 @@ module.exports = class ProblemAlertView extends CocoView
return unless @problem? return unless @problem?
@$el.show() unless @$el.is(":visible") @$el.show() unless @$el.is(":visible")
@$el.addClass 'jiggling' @$el.addClass 'jiggling'
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'error_appear', volume: 1.0 @playSound 'error_appear'
pauseJiggle = => pauseJiggle = =>
@$el?.removeClass 'jiggling' @$el?.removeClass 'jiggling'
_.delay pauseJiggle, 1000 _.delay pauseJiggle, 1000

View file

@ -83,7 +83,7 @@ module.exports = class SpellListTabEntryView extends SpellListEntryView
content: docFormatter.formatPopover() content: docFormatter.formatPopover()
container: @$el.parent() container: @$el.parent()
).on 'show.bs.popover', => ).on 'show.bs.popover', =>
Backbone.Mediator.publish 'audio-player:play-sound', trigger: "spell-tab-entry-open", volume: 0.75 @playSound 'spell-tab-entry-open', 0.75
onMouseEnterAvatar: (e) -> # Don't call super onMouseEnterAvatar: (e) -> # Don't call super
onMouseLeaveAvatar: (e) -> # Don't call super onMouseLeaveAvatar: (e) -> # Don't call super
@ -94,7 +94,7 @@ module.exports = class SpellListTabEntryView extends SpellListEntryView
onDropdownClick: (e) -> onDropdownClick: (e) ->
return unless @controlsEnabled return unless @controlsEnabled
Backbone.Mediator.publish 'tome:toggle-spell-list', {} Backbone.Mediator.publish 'tome:toggle-spell-list', {}
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'spell-list-open', volume: 1 @playSound 'spell-list-open'
onCodeReload: (e) -> onCodeReload: (e) ->
#return unless @controlsEnabled #return unless @controlsEnabled

View file

@ -51,7 +51,7 @@ module.exports = class SpellPaletteEntryView extends CocoView
).on 'shown.bs.popover', => ).on 'shown.bs.popover', =>
Backbone.Mediator.publish 'tome:palette-hovered', thang: @thang, prop: @doc.name, entry: @ Backbone.Mediator.publish 'tome:palette-hovered', thang: @thang, prop: @doc.name, entry: @
soundIndex = Math.floor(Math.random() * 4) soundIndex = Math.floor(Math.random() * 4)
Backbone.Mediator.publish 'audio-player:play-sound', trigger: "spell-palette-entry-open-#{soundIndex}", volume: 0.75 @playSound "spell-palette-entry-open-#{soundIndex}", 0.75
popover = @$el.data('bs.popover') popover = @$el.data('bs.popover')
popover?.$tip?.i18n() popover?.$tip?.i18n()
codeLanguage = @options.language codeLanguage = @options.language
@ -95,7 +95,7 @@ module.exports = class SpellPaletteEntryView extends CocoView
@$el.add('.spell-palette-popover.popover').removeClass 'pinned' @$el.add('.spell-palette-popover.popover').removeClass 'pinned'
$('.spell-palette-popover.popover .close').remove() $('.spell-palette-popover.popover .close').remove()
@$el.popover 'hide' @$el.popover 'hide'
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'spell-palette-entry-unpin', volume: 1 @playSound 'spell-palette-entry-unpin'
else else
@popoverPinned = true @popoverPinned = true
@$el.popover 'show' @$el.popover 'show'
@ -103,7 +103,7 @@ module.exports = class SpellPaletteEntryView extends CocoView
x = $('<button type="button" data-dismiss="modal" aria-hidden="true" class="close">×</button>') x = $('<button type="button" data-dismiss="modal" aria-hidden="true" class="close">×</button>')
$('.spell-palette-popover.popover').append x $('.spell-palette-popover.popover').append x
x.on 'click', @onClick x.on 'click', @onClick
Backbone.Mediator.publish 'audio-player:play-sound', trigger: 'spell-palette-entry-pin', volume: 1 @playSound 'spell-palette-entry-pin'
Backbone.Mediator.publish 'tome:palette-pin-toggled', entry: @, pinned: @popoverPinned Backbone.Mediator.publish 'tome:palette-pin-toggled', entry: @, pinned: @popoverPinned
onClick: (e) => onClick: (e) =>

View file

@ -103,7 +103,7 @@ module.exports = class PollModal extends ModalView
if Math.random() < randomGems / 40 if Math.random() < randomGems / 40
gemTrigger = 'gem-' + (gemNoisesPlayed % 4) # 4 gem sounds gemTrigger = 'gem-' + (gemNoisesPlayed % 4) # 4 gem sounds
++gemNoisesPlayed ++gemNoisesPlayed
Backbone.Mediator.publish 'audio-player:play-sound', trigger: gemTrigger, volume: 0.475 + i / 2000 @playSound gemTrigger, (0.475 + i / 2000)
@$randomNumber.delay 25 @$randomNumber.delay 25
@$randomGems.delay(1100).queue -> @$randomGems.delay(1100).queue ->
utils.replaceText $(@), commentStart + randomGems utils.replaceText $(@), commentStart + randomGems

View file

@ -3,7 +3,7 @@ _ = require 'lodash'
_.str = require 'underscore.string' _.str = require 'underscore.string'
sysPath = require 'path' sysPath = require 'path'
fs = require('fs') fs = require('fs')
commonjsHeader = fs.readFileSync('node_modules/brunch/node_modules/commonjs-require-definition/require.js', {encoding: 'utf8'}) commonjsHeader = require('commonjs-require-definition')
TRAVIS = process.env.COCO_TRAVIS_TEST TRAVIS = process.env.COCO_TRAVIS_TEST

View file

@ -83,6 +83,7 @@
"coffee-script-brunch": "https://github.com/brunch/coffee-script-brunch/tarball/master", "coffee-script-brunch": "https://github.com/brunch/coffee-script-brunch/tarball/master",
"coffeelint-brunch": "> 1.0 < 1.8", "coffeelint-brunch": "> 1.0 < 1.8",
"compressible": "~1.0.1", "compressible": "~1.0.1",
"commonjs-require-definition": "~0.2.0",
"css-brunch": "> 1.0 < 1.8", "css-brunch": "> 1.0 < 1.8",
"jade": "0.33.x", "jade": "0.33.x",
"jade-brunch": "> 1.0 < 1.8", "jade-brunch": "> 1.0 < 1.8",

View file

@ -1,6 +1,7 @@
AnalyticsString = require '../analytics/AnalyticsString' AnalyticsString = require '../analytics/AnalyticsString'
log = require 'winston' log = require 'winston'
mongoose = require 'mongoose' mongoose = require 'mongoose'
config = require '../../server_config'
module.exports = module.exports =
isID: (id) -> _.isString(id) and id.length is 24 and id.match(/[a-f0-9]/gi)?.length is 24 isID: (id) -> _.isString(id) and id.length is 24 and id.match(/[a-f0-9]/gi)?.length is 24
@ -21,6 +22,9 @@ module.exports =
# Grabs latest subscription (e.g. in case of a resubscribe) # Grabs latest subscription (e.g. in case of a resubscribe)
return done() unless customerID? return done() unless customerID?
return done() unless options.subscriptionID? or options.userID? return done() unless options.subscriptionID? or options.userID?
# Some prepaid tests were calling this in such a way that stripe wasn't defined.
stripe = require('stripe')(config.stripe.secretKey) unless stripe
subscriptionID = options.subscriptionID subscriptionID = options.subscriptionID
userID = options.userID userID = options.userID

View file

@ -14,6 +14,7 @@ User = require '../users/User'
{findStripeSubscription} = require '../lib/utils' {findStripeSubscription} = require '../lib/utils'
{getSponsoredSubsAmount} = require '../../app/core/utils' {getSponsoredSubsAmount} = require '../../app/core/utils'
StripeUtils = require '../lib/stripe_utils' StripeUtils = require '../lib/stripe_utils'
moment = require 'moment'
recipientCouponID = 'free' recipientCouponID = 'free'
@ -38,6 +39,7 @@ class SubscriptionHandler extends Handler
return @getStripeSubscriptions(req, res) if args[1] is 'stripe_subscriptions' return @getStripeSubscriptions(req, res) if args[1] is 'stripe_subscriptions'
return @getSubscribers(req, res) if args[1] is 'subscribers' return @getSubscribers(req, res) if args[1] is 'subscribers'
return @purchaseYearSale(req, res) if args[1] is 'year_sale' return @purchaseYearSale(req, res) if args[1] is 'year_sale'
return @subscribeWithPrepaidCode(req, res) if args[1] is 'subscribe_prepaid'
super(arguments...) super(arguments...)
getStripeEvents: (req, res) -> getStripeEvents: (req, res) ->
@ -117,11 +119,7 @@ class SubscriptionHandler extends Handler
log.debug 'Analytics error:\n' + err log.debug 'Analytics error:\n' + err
@sendSuccess(res, userMap) @sendSuccess(res, userMap)
purchaseYearSale: (req, res) -> cancelSubscriptionImmediately: (user, subscription, done) =>
return @sendForbiddenError(res) unless req.user?
return @sendForbiddenError(res) if req.user?.get('stripe')?.sponsorID
cancelSubscriptionImmediately = (user, subscription, done) =>
return done() unless user and subscription return done() unless user and subscription
stripe.customers.cancelSubscription subscription.customer, subscription.id, (err) => stripe.customers.cancelSubscription subscription.customer, subscription.id, (err) =>
return done(err) if err return done(err) if err
@ -134,6 +132,11 @@ class SubscriptionHandler extends Handler
return done(err) if err return done(err) if err
done() done()
purchaseYearSale: (req, res) ->
return @sendForbiddenError(res) unless req.user?
return @sendForbiddenError(res) if req.user?.get('stripe')?.sponsorID
StripeUtils.getCustomer req.user, req.body.stripe?.token, (err, customer) => StripeUtils.getCustomer req.user, req.body.stripe?.token, (err, customer) =>
if err if err
@logSubscriptionError(req.user, "Purchase year sale get customer: #{JSON.stringify(err)}") @logSubscriptionError(req.user, "Purchase year sale get customer: #{JSON.stringify(err)}")
@ -142,7 +145,7 @@ class SubscriptionHandler extends Handler
findStripeSubscription customer.id, subscriptionID: req.user.get('stripe')?.subscriptionID, (subscription) => findStripeSubscription customer.id, subscriptionID: req.user.get('stripe')?.subscriptionID, (subscription) =>
stripeSubscriptionPeriodEndDate = new Date(subscription.current_period_end * 1000) if subscription stripeSubscriptionPeriodEndDate = new Date(subscription.current_period_end * 1000) if subscription
cancelSubscriptionImmediately req.user, subscription, (err) => @cancelSubscriptionImmediately req.user, subscription, (err) =>
if err if err
@logSubscriptionError(user, "Purchase year sale Stripe cancel subscription error: #{JSON.stringify(err)}") @logSubscriptionError(user, "Purchase year sale Stripe cancel subscription error: #{JSON.stringify(err)}")
return @sendDatabaseError(res, err) return @sendDatabaseError(res, err)
@ -192,6 +195,81 @@ class SubscriptionHandler extends Handler
@logSubscriptionError(req.user, "Year sub sale HipChat tower msg error: #{JSON.stringify(error)}") @logSubscriptionError(req.user, "Year sub sale HipChat tower msg error: #{JSON.stringify(error)}")
@sendSuccess(res, user) @sendSuccess(res, user)
subscribeWithPrepaidCode: (req, res) ->
return @sendForbiddenError(res) unless req.user?
return @sendBadInputError(res,"You must provide a valid prepaid code") unless req.body?.ppc
# Check if code exists and has room for more redeemers
Prepaid.findOne({ code: req.body.ppc?.toString() }).exec (err, prepaid) =>
if err
@logSubscriptionError(req.user, "Redeem Prepaid Code find: #{JSON.stringify(err)}")
return @sendDatabaseError(res, err)
unless prepaid
@logSubscriptionError(req.user, "Could not find prepaid code #{req.body.ppc}")
return @sendForbiddenError(res)
oldRedeemers = prepaid.get('redeemers') ? []
return @sendForbiddenError(res) if oldRedeemers.length >= prepaid.get('maxRedeemers')
months = parseInt(prepaid.get('properties')?.months)
return @sendForbiddenError(res) if isNaN(months) or months < 1
for redeemer in oldRedeemers
return @sendForbiddenError(res) if redeemer.userID.equals(req.user._id)
customerID = req.user.get('stripe')?.customerID
subscriptionID = req.user.get('stripe')?.subscriptionID
findStripeSubscription customerID, subscriptionID: subscriptionID, (subscription) =>
stripeSubscriptionPeriodEndDate = new Date(subscription.current_period_end * 1000) if subscription
@cancelSubscriptionImmediately req.user, subscription, (err) =>
if err
@logSubscriptionError(user, "Redeem Prepaid Code Stripe cancel subscription error: #{JSON.stringify(err)}")
return @sendDatabaseError(res, err)
@redeemPrepaidCode(req, res, oldRedeemers, months, stripeSubscriptionPeriodEndDate)
redeemPrepaidCode: (req, res, oldRedeemers, months, startDate=null) =>
return @sendForbiddenError(res) unless req.user?
return @sendForbiddenError(res) unless req.body?.ppc
return @sendForbiddenError(res) unless oldRedeemers
return @sendForbiddenError(res) if isNaN(months) or months < 1
newRedeemerPush = { $push: { redeemers : { date: new Date().toISOString(), userID: req.user._id } }}
# Only update the prepaid document if the length of the redeemers array hasn't changed in the db.
# This will probably fail if redeemers isn't defined. new terminal_subscriptions created should be sure to set the redeemers array
# TODO: find a better way?
Prepaid.update { 'code': req.body.ppc, 'redeemers': { $size: oldRedeemers.length }}, newRedeemerPush, (err, num, info) =>
if err
@logSubscriptionError(req.user, "Subscribe with Prepaid Code update: #{JSON.stringify(err)}")
return @sendDatabaseError(res, err)
return @sendNotFoundError(res, "Error while updating prepaid redeemer") if num isnt 1
# Add terminal subscription to User, extending existing subscriptions
# TODO: refactor this into some form useable by both this and purchaseYearSale
stripeInfo = _.cloneDeep(req.user.get('stripe') ? {})
endDate = new moment()
if startDate
endDate = new moment(startDate)
else if _.isString(stripeInfo.free) and new moment().isBefore(new moment(stripeInfo.free))
endDate = new moment(stripeInfo.free)
endDate = endDate.add(months, 'months')
stripeInfo.free = endDate.toISOString().substring(0, 10)
req.user.set('stripe', stripeInfo)
# Add gems to User
purchased = _.clone(req.user.get('purchased'))
purchased ?= {}
purchased.gems ?= 0
purchased.gems += subscriptions.basic.gems * months
req.user.set('purchased', purchased)
req.user.save (err, user) =>
if err
@logSubscriptionError(req.user, "User save error: #{JSON.stringify(err)}")
return @sendDatabaseError(res, err)
@sendSuccess(res, user)
subscribeUser: (req, user, done) -> subscribeUser: (req, user, done) ->
if (not req.user) or req.user.isAnonymous() or user.isAnonymous() if (not req.user) or req.user.isAnonymous() or user.isAnonymous()
return done({res: 'You must be signed in to subscribe.', code: 403}) return done({res: 'You must be signed in to subscribe.', code: 403})

View file

@ -2,6 +2,8 @@ mongoose = require 'mongoose'
config = require '../../server_config' config = require '../../server_config'
PrepaidSchema = new mongoose.Schema {}, {strict: false, minimize: false,read:config.mongo.readpref} PrepaidSchema = new mongoose.Schema {}, {strict: false, minimize: false,read:config.mongo.readpref}
PrepaidSchema.index({code: 1}, { unique: true })
PrepaidSchema.statics.generateNewCode = (done) -> PrepaidSchema.statics.generateNewCode = (done) ->
tryCode = -> tryCode = ->
code = _.sample("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 8).join('') code = _.sample("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 8).join('')

View file

@ -1,5 +1,7 @@
Handler = require '../commons/Handler' Handler = require '../commons/Handler'
Prepaid = require './Prepaid' Prepaid = require './Prepaid'
StripeUtils = require '../lib/stripe_utils'
{getPrepaidCodeAmount} = require '../../app/core/utils'
# TODO: Should this happen on a save() call instead of a prepaid/-/create post? # TODO: Should this happen on a save() call instead of a prepaid/-/create post?
# TODO: Probably a better way to create a unique 8 charactor string property using db voodoo # TODO: Probably a better way to create a unique 8 charactor string property using db voodoo
@ -7,29 +9,111 @@ Prepaid = require './Prepaid'
PrepaidHandler = class PrepaidHandler extends Handler PrepaidHandler = class PrepaidHandler extends Handler
modelClass: Prepaid modelClass: Prepaid
jsonSchema: require '../../app/schemas/models/prepaid.schema' jsonSchema: require '../../app/schemas/models/prepaid.schema'
allowedMethods: ['POST'] allowedMethods: ['GET','POST']
baseAmount: 999
logPurchaseError: (user, msg) ->
console.warn "Prepaid Purchase Error: [#{user.get('slug')} (#{user._id})] '#{msg}'"
hasAccess: (req) -> hasAccess: (req) ->
req.user?.isAdmin() req.user?.isAdmin()
getByRelationship: (req, res, args...) -> getByRelationship: (req, res, args...) ->
relationship = args[1] relationship = args[1]
return @getPrepaid(req, res, args[2]) if relationship is 'code'
return @createPrepaid(req, res) if relationship is 'create' return @createPrepaid(req, res) if relationship is 'create'
return @purchasePrepaid(req, res) if relationship is 'purchase'
super arguments... super arguments...
getPrepaid: (req, res, code) ->
return @sendForbiddenError(res) unless req.user?
return @sendNotFoundError(res, "You must specify a code") unless code
Prepaid.findOne({ code: code.toString() }).exec (err, prepaid) =>
if err
console.warn "Get Prepaid Code Error [#{req.user.get('slug')} (#{req.user.id})]: #{JSON.stringify(err)}"
return @sendDatabaseError(res, err)
return @sendNotFoundError(res, "Code not found") unless prepaid
@sendSuccess(res, prepaid.toObject())
createPrepaid: (req, res) -> createPrepaid: (req, res) ->
return @sendForbiddenError(res) unless @hasAccess(req) return @sendForbiddenError(res) unless @hasAccess(req)
return @sendForbiddenError(res) unless req.body.type is 'subscription' return @sendForbiddenError(res) unless req.body.type in ['subscription','terminal_subscription']
return @sendForbiddenError(res) unless req.body.maxRedeemers > 0 return @sendForbiddenError(res) unless req.body.maxRedeemers > 0
Prepaid.generateNewCode (code) => Prepaid.generateNewCode (code) =>
return @sendDatabaseError(res, 'Database error.') unless code return @sendDatabaseError(res, 'Database error.') unless code
prepaid = new Prepaid # TODO: change the creator to use ObjectID like with the terminal_subscription
options =
creator: req.user.id creator: req.user.id
type: req.body.type type: req.body.type
code: code code: code
maxRedeemers: req.body.maxRedeemers maxRedeemers: req.body.maxRedeemers
properties: {}
redeemers: []
if req.body.type is 'subscription'
options.properties.couponID = 'free'
if req.body.type is 'terminal_subscription'
options.properties.months = req.body.months
options.creator = req.user._id
prepaid = new Prepaid options
prepaid.save (err) =>
return @sendDatabaseError(res, err) if err
@sendSuccess(res, prepaid.toObject())
purchasePrepaid: (req, res) ->
return @sendForbiddenError(res) unless req.user?
return @sendForbiddenError(res) unless req.body.type is 'terminal_subscription'
maxRedeemers = parseInt(req.body.maxRedeemers)
months = parseInt(req.body.months)
return @sendForbiddenError(res) unless isNaN(maxRedeemers) is false and maxRedeemers > 0
return @sendForbiddenError(res) unless isNaN(months) is false and months > 0
return @sendError(res, 403, "Users or Months must be greater than 3") if maxRedeemers < 3 and months < 3
StripeUtils.getCustomer req.user, req.body.stripe?.token, (err, customer) =>
if err
@logPurchaseError(req.user, "getCustomer error: #{JSON.stringify(err)}")
return @sendDatabaseError(res, err)
metadata =
type: req.body.type
userID: req.user._id + ''
timestamp: parseInt(req.body.stripe?.timestamp)
description: req.body.description
maxRedeemers: maxRedeemers
months: months
productID: 'prepaid ' + req.body.type
amount = getPrepaidCodeAmount(@baseAmount, maxRedeemers, months)
StripeUtils.createCharge req.user, amount, metadata, (err, charge) =>
if err
@logPurchaseError(req.user, "createCharge error: #{JSON.stringify(err)}")
return @sendDatabaseError(res, err)
StripeUtils.createPayment req.user, charge, (err, payment) =>
if err
@logPurchaseError(req.user, "createPayment error: #{JSON.stringify(err)}")
return @sendDatabaseError(res, err)
Prepaid.generateNewCode (code) =>
return @sendDatabaseError(res, 'Database error.') unless code
prepaid = new Prepaid
creator: req.user._id
type: req.body.type
code: code
maxRedeemers: req.body.maxRedeemers
redeemers: []
properties: properties:
couponID: 'free' months: req.body.months
prepaid.save (err) => prepaid.save (err) =>
return @sendDatabaseError(res, err) if err return @sendDatabaseError(res, err) if err
@sendSuccess(res, prepaid.toObject()) @sendSuccess(res, prepaid.toObject())

View file

@ -46,14 +46,18 @@ module.exports.setup = (app) ->
invoiceID = req.body.data.object.id invoiceID = req.body.data.object.id
stripe.invoices.retrieve invoiceID, (err, invoice) => stripe.invoices.retrieve invoiceID, (err, invoice) =>
return res.send(500, '') if err if err
logStripeWebhookError("Retrieve invoice error: #{JSON.stringify(err)}")
return res.send(500, '')
unless invoice.total or invoice.discount?.coupon?.id is 'free' unless invoice.total or invoice.discount?.coupon?.id is 'free'
# invoices made when trialing, probably given for people who resubscribe after unsubscribing # invoices made when trialing, probably given for people who resubscribe after unsubscribing
return res.send(200, '') return res.send(200, '')
return res.send(200, '') unless invoice.lines?.data?.length > 0 return res.send(200, '') unless invoice.lines?.data?.length > 0
getUserID invoice.customer, (err, userID) => getUserID invoice.customer, (err, userID) =>
return res.send(500, '') if err if err
logStripeWebhookError("Get user ID error: #{JSON.stringify(err)}")
return res.send(500, '')
# User is recipient if no metadata.id # User is recipient if no metadata.id
recipientID = invoice.lines.data[0].metadata?.id or userID recipientID = invoice.lines.data[0].metadata?.id or userID
@ -62,7 +66,9 @@ module.exports.setup = (app) ->
subscriptionID = invoice.lines.data[0].subscription or invoice.lines.data[0].id subscriptionID = invoice.lines.data[0].subscription or invoice.lines.data[0].id
User.findById recipientID, (err, recipient) => User.findById recipientID, (err, recipient) =>
return res.send(500, '') if err if err
logStripeWebhookError("Find recipient user error: #{JSON.stringify(err)}")
return res.send(500, '')
return res.send(200) unless recipient # just for the sake of testing... return res.send(200) unless recipient # just for the sake of testing...
Payment.findOne {'stripe.invoiceID': invoiceID}, (err, payment) => Payment.findOne {'stripe.invoiceID': invoiceID}, (err, payment) =>
@ -82,7 +88,9 @@ module.exports.setup = (app) ->
payment.set 'gems', 3500 if invoice.lines.data[0].plan?.id is 'basic' payment.set 'gems', 3500 if invoice.lines.data[0].plan?.id is 'basic'
payment.save (err) => payment.save (err) =>
return res.send(500, '') if err if err
logStripeWebhookError("Save payment error: #{JSON.stringify(err)}")
return res.send(500, '')
return res.send(201, '') if invoice.lines.data[0].plan?.id isnt 'basic' return res.send(201, '') if invoice.lines.data[0].plan?.id isnt 'basic'
# Update purchased gems # Update purchased gems
@ -94,7 +102,9 @@ module.exports.setup = (app) ->
purchased.gems = gems purchased.gems = gems
recipient.set('purchased', purchased) recipient.set('purchased', purchased)
recipient.save (err) -> recipient.save (err) ->
return res.send(500, '') if err if err
logStripeWebhookError("Save recipient user error: #{JSON.stringify(err)}")
return res.send(500, '')
return res.send(201, '') return res.send(201, '')
handleSubscriptionDeleted = (req, res) -> handleSubscriptionDeleted = (req, res) ->

View file

@ -23,6 +23,7 @@ UserRemark = require './remarks/UserRemark'
{isID} = require '../lib/utils' {isID} = require '../lib/utils'
hipchat = require '../hipchat' hipchat = require '../hipchat'
sendwithus = require '../sendwithus' sendwithus = require '../sendwithus'
Prepaid = require '../prepaids/Prepaid'
serverProperties = ['passwordHash', 'emailLower', 'nameLower', 'passwordReset', 'lastIP'] serverProperties = ['passwordHash', 'emailLower', 'nameLower', 'passwordReset', 'lastIP']
candidateProperties = [ candidateProperties = [
@ -305,6 +306,7 @@ UserHandler = class UserHandler extends Handler
return @avatar(req, res, args[0]) if args[1] is 'avatar' return @avatar(req, res, args[0]) if args[1] is 'avatar'
return @getByIDs(req, res) if args[1] is 'users' return @getByIDs(req, res) if args[1] is 'users'
return @getNamesByIDs(req, res) if args[1] is 'names' return @getNamesByIDs(req, res) if args[1] is 'names'
return @getPrepaidCodes(req, res) if args[1] is 'prepaid_codes'
return @nameToID(req, res, args[0]) if args[1] is 'nameToID' return @nameToID(req, res, args[0]) if args[1] is 'nameToID'
return @getLevelSessionsForEmployer(req, res, args[0]) if args[1] is 'level.sessions' and args[2] is 'employer' return @getLevelSessionsForEmployer(req, res, args[0]) if args[1] is 'level.sessions' and args[2] is 'employer'
return @getLevelSessions(req, res, args[0]) if args[1] is 'level.sessions' return @getLevelSessions(req, res, args[0]) if args[1] is 'level.sessions'
@ -453,6 +455,11 @@ UserHandler = class UserHandler extends Handler
sendMail emailParams sendMail emailParams
getPrepaidCodes: (req, res) ->
orQuery = [{ creator: req.user._id }, { 'redeemers.userID' : req.user._id }]
Prepaid.find({}).or(orQuery).exec (err, documents) =>
@sendSuccess(res, documents)
agreeToCLA: (req, res) -> agreeToCLA: (req, res) ->
return @sendForbiddenError(res) unless req.user return @sendForbiddenError(res) unless req.user
doc = doc =

View file

@ -120,11 +120,33 @@ wrapUpGetUser = (email, user, done) ->
GLOBAL.getURL = (path) -> GLOBAL.getURL = (path) ->
return 'http://localhost:3001' + path return 'http://localhost:3001' + path
GLOBAL.createPrepaid = (type, maxRedeemers, done) -> GLOBAL.createPrepaid = (type, maxRedeemers, months, done) ->
options = uri: GLOBAL.getURL('/db/prepaid/-/create') options = uri: GLOBAL.getURL('/db/prepaid/-/create')
options.json = options.json =
type: type type: type
maxRedeemers: maxRedeemers maxRedeemers: maxRedeemers
if months
options.json.months = months
request.post options, done
GLOBAL.fetchPrepaid = (ppc, done) ->
options = uri: GLOBAL.getURL('/db/prepaid/-/code/'+ppc)
request.get options, done
GLOBAL.purchasePrepaid = (type, maxRedeemers, months, done) ->
options = uri: GLOBAL.getURL('/db/prepaid/-/purchase')
options.json =
type: type
maxRedeemers: maxRedeemers
months: months
stripe:
timestamp: new Date().getTime()
request.post options, done
GLOBAL.subscribeWithPrepaid = (ppc, done) =>
options = url: GLOBAL.getURL('/db/subscription/-/subscribe_prepaid')
options.json =
ppc: ppc
request.post options, done request.post options, done
newUserCount = 0 newUserCount = 0

View file

@ -1,9 +1,18 @@
require '../common' require '../common'
config = require '../../../server_config'
moment = require 'moment'
{findStripeSubscription} = require '../../../server/lib/utils'
describe '/db/prepaid', -> describe '/db/prepaid', ->
prepaidURL = getURL('/db/prepaid') prepaidURL = getURL('/db/prepaid')
prepaidCreateURL = getURL('/db/prepaid/-/create') prepaidCreateURL = getURL('/db/prepaid/-/create')
headers = {'X-Change-Plan': 'true'}
joeData = null
stripe = require('stripe')(config.stripe.secretKey)
joeCode = null
verifyPrepaid = (user, prepaid, done) -> verifyPrepaid = (user, prepaid, done) ->
expect(prepaid.creator).toEqual(user.id) expect(prepaid.creator).toEqual(user.id)
expect(prepaid.type).toEqual('subscription') expect(prepaid.type).toEqual('subscription')
@ -13,12 +22,12 @@ describe '/db/prepaid', ->
done() done()
it 'Clear database users and prepaids', (done) -> it 'Clear database users and prepaids', (done) ->
clearModels [User, Prepaid], (err) -> clearModels [User, Prepaid, Payment], (err) ->
throw err if err throw err if err
done() done()
it 'Anonymous creates prepaid code', (done) -> it 'Anonymous creates prepaid code', (done) ->
createPrepaid 'subscription', 1, (err, res, body) -> createPrepaid 'subscription', 1, 0, (err, res, body) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(res.statusCode).toBe(401) expect(res.statusCode).toBe(401)
done() done()
@ -26,7 +35,7 @@ describe '/db/prepaid', ->
it 'Non-admin creates prepaid code', (done) -> it 'Non-admin creates prepaid code', (done) ->
loginNewUser (user1) -> loginNewUser (user1) ->
expect(user1.isAdmin()).toEqual(false) expect(user1.isAdmin()).toEqual(false)
createPrepaid 'subscription', 4, (err, res, body) -> createPrepaid 'subscription', 4, 0, (err, res, body) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(res.statusCode).toBe(403) expect(res.statusCode).toBe(403)
done() done()
@ -37,18 +46,35 @@ describe '/db/prepaid', ->
user1.save (err, user1) -> user1.save (err, user1) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(user1.isAdmin()).toEqual(true) expect(user1.isAdmin()).toEqual(true)
createPrepaid 'subscription', 1, (err, res, body) -> createPrepaid 'subscription', 1, 0, (err, res, body) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
verifyPrepaid user1, body, done verifyPrepaid user1, body, done
it 'Admin creates prepaid code with type terminal_subscription', (done) ->
loginNewUser (user1) ->
user1.set('permissions', ['admin'])
user1.save (err, user1) ->
expect(err).toBeNull()
expect(user1.isAdmin()).toEqual(true)
createPrepaid 'terminal_subscription', 2, 3, (err, res, body) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
expect(body.creator).toEqual(user1.id)
expect(body.type).toEqual('terminal_subscription')
expect(body.maxRedeemers).toEqual(2)
expect(body.properties?.months).toEqual(3)
expect(body.code).toMatch(/^\w{8}$/)
done()
it 'Admin creates prepaid code with invalid type', (done) -> it 'Admin creates prepaid code with invalid type', (done) ->
loginNewUser (user1) -> loginNewUser (user1) ->
user1.set('permissions', ['admin']) user1.set('permissions', ['admin'])
user1.save (err, user1) -> user1.save (err, user1) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(user1.isAdmin()).toEqual(true) expect(user1.isAdmin()).toEqual(true)
createPrepaid 'bulldozer', 1, (err, res, body) -> createPrepaid 'bulldozer', 1, 0, (err, res, body) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(res.statusCode).toBe(403) expect(res.statusCode).toBe(403)
done() done()
@ -59,7 +85,7 @@ describe '/db/prepaid', ->
user1.save (err, user1) -> user1.save (err, user1) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(user1.isAdmin()).toEqual(true) expect(user1.isAdmin()).toEqual(true)
createPrepaid null, 1, (err, res, body) -> createPrepaid null, 1, 0, (err, res, body) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(res.statusCode).toBe(403) expect(res.statusCode).toBe(403)
done() done()
@ -70,7 +96,7 @@ describe '/db/prepaid', ->
user1.save (err, user1) -> user1.save (err, user1) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(user1.isAdmin()).toEqual(true) expect(user1.isAdmin()).toEqual(true)
createPrepaid 'subscription', 0, (err, res, body) -> createPrepaid 'subscription', 0, 0, (err, res, body) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(res.statusCode).toBe(403) expect(res.statusCode).toBe(403)
done() done()
@ -89,7 +115,7 @@ describe '/db/prepaid', ->
user1.save (err, user1) -> user1.save (err, user1) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(user1.isAdmin()).toEqual(true) expect(user1.isAdmin()).toEqual(true)
createPrepaid 'subscription', 1, (err, res, prepaid) -> createPrepaid 'subscription', 1, 0, (err, res, prepaid) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
request.get {uri: prepaidURL}, (err, res, body) -> request.get {uri: prepaidURL}, (err, res, body) ->
@ -104,3 +130,233 @@ describe '/db/prepaid', ->
break break
expect(found).toEqual(true) expect(found).toEqual(true)
done() unless found done() unless found
# *** Purchase Prepaid Codes *** #
it 'Anonymous submits a prepaid purchase', (done) ->
logoutUser () ->
purchasePrepaid 'terminal_subscription', 3, 3, (err, res, prepaid) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(401)
done()
it 'Should error if type isnt terminal_subscription', (done) ->
loginNewUser (user1) ->
purchasePrepaid 'subscription', 3, 3, (err, res, prepaid) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(403)
done()
it 'Should error if maxRedeemers is invalid', (done) ->
loginNewUser (user1) ->
purchasePrepaid 'terminal_subscription', -1, 3, (err, res, prepaid) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(403)
done()
purchasePrepaid 'terminal_subscription', 'foo', 3, (err, res, prepaid) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(403)
done()
it 'Should error if months is invalid', (done) ->
loginNewUser (user1) ->
purchasePrepaid 'terminal_subscription', 3, -1, (err, res, prepaid) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(403)
done()
purchasePrepaid 'terminal_subscription', 3, 'foo', (err, res, prepaid) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(403)
done()
it 'Should error if maxRedeemers and months are less than 3', (done) ->
loginNewUser (user1) ->
purchasePrepaid 'terminal_subscription', 1, 1, (err, res, prepaid) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(403)
done()
it 'User submits valid prepaid code purchase', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
stripeTokenID = token.id
loginJoe (joe) ->
joeData = joe.toObject()
joeData.stripe = {
token: stripeTokenID
planID: 'basic'
}
request.put {uri: getURL('/db/user'), json: joeData, headers: headers }, (err, res, body) ->
joeData = body
expect(res.statusCode).toBe(200)
expect(joeData.stripe.customerID).toBeDefined()
expect(firstSubscriptionID = joeData.stripe.subscriptionID).toBeDefined()
expect(joeData.stripe.planID).toBe('basic')
expect(joeData.stripe.token).toBeUndefined()
purchasePrepaid 'terminal_subscription', 3, 3, (err, res, prepaid) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
expect(prepaid.type).toEqual('terminal_subscription')
expect(prepaid.code).toBeDefined()
# Saving this code for later tests
# TODO: don't make tests dependent on each other
joeCode = prepaid.code
expect(prepaid.creator).toBeDefined()
expect(prepaid.maxRedeemers).toEqual(3)
expect(prepaid.properties).toBeDefined()
expect(prepaid.properties.months).toEqual(3)
done()
it 'Should have logged a Payment with the correct amount', (done) ->
loginJoe (joe) ->
query =
purchaser: joe._id
Payment.find query, (err, payments) ->
expect(err).toBeNull()
expect(payments).not.toBeNull()
expect(payments.length).toEqual(1)
expect(payments[0].get('amount')).toEqual(8991)
done()
it 'Anonymous cant redeem a prepaid code', (done) ->
logoutUser () ->
subscribeWithPrepaid joeCode, (err, res) ->
expect(err).toBeNull()
expect(res?.statusCode).toEqual(401)
done()
it 'User cant redeem a nonexistant prepaid code', (done) ->
loginJoe (joe) ->
subscribeWithPrepaid 'abc123', (err, res) ->
expect(err).toBeNull()
expect(res.statusCode).toEqual(403)
done()
it 'User cant redeem empty code', (done) ->
loginJoe (joe) ->
subscribeWithPrepaid '', (err, res) ->
expect(err).toBeNull()
expect(res.statusCode).toEqual(422)
done()
it 'Anonymous cant fetch a prepaid code', (done) ->
expect(joeCode).not.toBeNull()
logoutUser () ->
fetchPrepaid joeCode, (err, res) ->
expect(err).toBeNull()
expect(res.statusCode).toEqual(403)
done()
it 'User can fetch a prepaid code', (done) ->
expect(joeCode).not.toBeNull()
loginJoe (joe) ->
fetchPrepaid joeCode, (err, res, body) ->
expect(err).toBeNull()
expect(res.statusCode).toEqual(200)
expect(body).toBeDefined()
return done() unless body
prepaid = JSON.parse(body)
expect(prepaid.code).toEqual(joeCode)
expect(prepaid.maxRedeemers).toEqual(3)
expect(prepaid.properties?.months).toEqual(3)
done()
# TODO: Move redeem subscription prepaid code tests to subscription tests file
it 'Creator can redeeem a prepaid code', (done) ->
loginJoe (joe) ->
expect(joeCode).not.toBeNull()
expect(joeData.stripe?.customerID).toBeDefined()
expect(joeData.stripe?.subscriptionID).toBeDefined()
return done() unless joeData.stripe?.customerID
# joe has a stripe subscription, so test if the months are added to the end of it.
stripe.customers.retrieve joeData.stripe.customerID, (err, customer) =>
expect(err).toBeNull()
findStripeSubscription customer.id, subscriptionID: joeData.stripe?.subscriptionID, (subscription) =>
if subscription
stripeSubscriptionPeriodEndDate = new moment(subscription.current_period_end * 1000)
else
expect(stripeSubscriptionPeriodEndDate).toBeDefined()
return done()
subscribeWithPrepaid joeCode, (err, res, result) =>
expect(err).toBeNull()
expect(res.statusCode).toEqual(200)
endDate = stripeSubscriptionPeriodEndDate.add(3, 'months').toISOString().substring(0, 10)
expect(result?.stripe?.free).toEqual(endDate)
expect(result?.purchased?.gems).toEqual(14000)
findStripeSubscription customer.id, subscriptionID: joeData.stripe?.subscriptionID, (subscription) =>
expect(subscription).toBeNull()
done()
it 'User can redeem a prepaid code', (done) ->
loginSam (sam) ->
subscribeWithPrepaid joeCode, (err, res, result) ->
expect(err).toBeNull()
expect(res.statusCode).toEqual(200)
endDate = new moment().add(3, 'months').toISOString().substring(0, 10)
expect(result?.stripe?.free).toEqual(endDate)
expect(result?.purchased?.gems).toEqual(10500)
done()
it 'Wont allow the same person to redeem twice', (done) ->
loginSam (sam) ->
subscribeWithPrepaid joeCode, (err, res, result) ->
expect(err).toBeNull()
expect(res.statusCode).toEqual(403)
done()
it 'Will return redeemed code as part of codes list', (done) ->
loginSam (sam) ->
request.get "#{getURL('/db/user')}/#{sam.id}/prepaid_codes", (err, res) ->
expect(err).toBeNull()
expect(res.statusCode).toEqual(200)
codes = JSON.parse res.body
expect(codes.length).toEqual(1)
done()
it 'Third user can redeem a prepaid code', (done) ->
loginNewUser (user) ->
subscribeWithPrepaid joeCode, (err, res, result) ->
expect(err).toBeNull()
expect(res.statusCode).toEqual(200)
endDate = new moment().add(3, 'months').toISOString().substring(0, 10)
expect(result?.stripe?.free).toEqual(endDate)
expect(result?.purchased?.gems).toEqual(10500)
done()
it 'Fourth user cannot redeem code', (done) ->
loginNewUser (user) ->
subscribeWithPrepaid joeCode, (err, res, result) ->
expect(err).toBeNull()
expect(res.statusCode).toEqual(403)
done()
it 'Can fetch a list of purchased and redeemed prepaid codes', (done) ->
loginJoe (joe) ->
purchasePrepaid 'terminal_subscription', 3, 1, (err, res, prepaid) ->
request.get "#{getURL('/db/user')}/#{joe.id}/prepaid_codes", (err, res) ->
expect(err).toBeNull()
expect(res.statusCode).toEqual(200);
codes = JSON.parse res.body
expect(codes.length).toEqual(2)
expect(codes[0].maxRedeemers).toEqual(3)
expect(codes[0].properties).toBeDefined()
expect(codes[0].properties.months).toEqual(3)
done()
it 'Test for injection', (done) ->
loginNewUser (user) ->
code = { $exists: true }
subscribeWithPrepaid code, (err, res, result) ->
expect(err).toBeNull()
expect(res.statusCode).not.toEqual(200)
done()
# TODO: add a bunch of parallel tests trying to redeem a code with a high maxRedeemers (50?) to see what happens

View file

@ -297,6 +297,8 @@ describe 'Subscriptions', ->
return done() unless sponsorCustomerID and sponsorStripe.sponsorSubscriptionID return done() unless sponsorCustomerID and sponsorStripe.sponsorSubscriptionID
stripe.customers.retrieveSubscription sponsorCustomerID, sponsorStripe.sponsorSubscriptionID, (err, subscription) -> stripe.customers.retrieveSubscription sponsorCustomerID, sponsorStripe.sponsorSubscriptionID, (err, subscription) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(subscription?).toBe(true)
return done() unless subscription?
expect(subscription.plan.amount).toEqual(1) expect(subscription.plan.amount).toEqual(1)
expect(subscription.customer).toEqual(sponsorCustomerID) expect(subscription.customer).toEqual(sponsorCustomerID)
expect(subscription.quantity).toEqual(utils.getSponsoredSubsAmount(subPrice, numSponsored, sponsorStripe.subscriptionID?)) expect(subscription.quantity).toEqual(utils.getSponsoredSubsAmount(subPrice, numSponsored, sponsorStripe.subscriptionID?))
@ -361,6 +363,7 @@ describe 'Subscriptions', ->
Payment.findOne paymentQuery, (err, payment) -> Payment.findOne paymentQuery, (err, payment) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(payment).not.toBeNull() expect(payment).not.toBeNull()
return done() if payment is null
expect(payment.get('amount')).toEqual(0) expect(payment.get('amount')).toEqual(0)
expect(payment.get('gems')).toBeGreaterThan(subGems - 1) expect(payment.get('gems')).toBeGreaterThan(subGems - 1)
done() done()
@ -406,6 +409,8 @@ describe 'Subscriptions', ->
expect(user.get('stripe').customerID).toBeDefined() expect(user.get('stripe').customerID).toBeDefined()
expect(user.get('stripe').planID).toBeUndefined() expect(user.get('stripe').planID).toBeUndefined()
expect(user.get('stripe').token).toBeUndefined() expect(user.get('stripe').token).toBeUndefined()
expect(user.get('stripe').subscriptionID).toBeDefined()
return done() unless user.get('stripe').subscriptionID
stripe.customers.retrieveSubscription user.get('stripe').customerID, user.get('stripe').subscriptionID, (err, subscription) -> stripe.customers.retrieveSubscription user.get('stripe').customerID, user.get('stripe').subscriptionID, (err, subscription) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(subscription).not.toBeNull() expect(subscription).not.toBeNull()
@ -422,11 +427,11 @@ describe 'Subscriptions', ->
expect(err).toBeNull() expect(err).toBeNull()
return done() if err return done() if err
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
expect(body.stripe.customerID).toBeDefined() expect(body.stripe?.customerID).toBeDefined()
updatedUser = body updatedUser = body
# Call webhooks for invoices # Call webhooks for invoices
options = customer: body.stripe.customerID, limit: 100 options = customer: body.stripe?.customerID, limit: 100
stripe.invoices.list options, (err, invoices) -> stripe.invoices.list options, (err, invoices) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(invoices).not.toBeNull() expect(invoices).not.toBeNull()
@ -444,7 +449,7 @@ describe 'Subscriptions', ->
unless invoice.id of invoicesWebHooked unless invoice.id of invoicesWebHooked
invoicesWebHooked[invoice.id] = true invoicesWebHooked[invoice.id] = true
webhookTasks.push makeWebhookCall(invoice) webhookTasks.push makeWebhookCall(invoice)
async.parallel webhookTasks, (err, results) -> async.series webhookTasks, (err, results) ->
expect(err?).toEqual(false) expect(err?).toEqual(false)
done(updatedUser) done(updatedUser)
@ -569,7 +574,7 @@ describe 'Subscriptions', ->
user1.save (err, user1) -> user1.save (err, user1) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(user1.isAdmin()).toEqual(true) expect(user1.isAdmin()).toEqual(true)
createPrepaid 'subscription', 1, (err, res, prepaid) -> createPrepaid 'subscription', 1, 0, (err, res, prepaid) ->
expect(err).toBeNull() expect(err).toBeNull()
subscribeUser user1, null, prepaid.code, -> subscribeUser user1, null, prepaid.code, ->
Prepaid.findById prepaid._id, (err, prepaid) -> Prepaid.findById prepaid._id, (err, prepaid) ->
@ -584,6 +589,8 @@ describe 'Subscriptions', ->
expect(err).toBeNull() expect(err).toBeNull()
stripeInfo = user1.get('stripe') stripeInfo = user1.get('stripe')
expect(stripeInfo.prepaidCode).toEqual(prepaid.get('code')) expect(stripeInfo.prepaidCode).toEqual(prepaid.get('code'))
expect(stripeInfo.subscriptionID).toBeDefined()
return done() unless stripeInfo.subscriptionID
# Delete subscription # Delete subscription
stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.subscriptionID, (err, subscription) -> stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.subscriptionID, (err, subscription) ->
@ -612,7 +619,7 @@ describe 'Subscriptions', ->
subscribeUser user1, token, null, -> subscribeUser user1, token, null, ->
User.findById user1.id, (err, user1) -> User.findById user1.id, (err, user1) ->
expect(err).toBeNull() expect(err).toBeNull()
createPrepaid 'subscription', 1, (err, res, prepaid) -> createPrepaid 'subscription', 1, 0, (err, res, prepaid) ->
expect(err).toBeNull() expect(err).toBeNull()
subscribeUser user1, null, prepaid.code, -> subscribeUser user1, null, prepaid.code, ->
Prepaid.findById prepaid._id, (err, prepaid) -> Prepaid.findById prepaid._id, (err, prepaid) ->
@ -627,6 +634,7 @@ describe 'Subscriptions', ->
stripe.customers.retrieveSubscription customerID, subscriptionID, (err, subscription) -> stripe.customers.retrieveSubscription customerID, subscriptionID, (err, subscription) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(subscription).not.toBeNull() expect(subscription).not.toBeNull()
return done() unless subscription
expect(subscription.discount?.coupon?.id).toEqual('free') expect(subscription.discount?.coupon?.id).toEqual('free')
done() done()
@ -646,7 +654,7 @@ describe 'Subscriptions', ->
request.put {uri: userURL, json: requestBody, headers: headers }, (err, res, updatedUser) -> request.put {uri: userURL, json: requestBody, headers: headers }, (err, res, updatedUser) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
createPrepaid 'subscription', 1, (err, res, prepaid) -> createPrepaid 'subscription', 1, 0, (err, res, prepaid) ->
subscribeUser user1, null, prepaid.code, -> subscribeUser user1, null, prepaid.code, ->
Prepaid.findById prepaid._id, (err, prepaid) -> Prepaid.findById prepaid._id, (err, prepaid) ->
expect(err).toBeNull() expect(err).toBeNull()
@ -660,7 +668,7 @@ describe 'Subscriptions', ->
stripe.customers.retrieveSubscription customerID, subscriptionID, (err, subscription) -> stripe.customers.retrieveSubscription customerID, subscriptionID, (err, subscription) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(subscription).not.toBeNull() expect(subscription).not.toBeNull()
expect(subscription.discount?.coupon?.id).toEqual('free') expect(subscription?.discount?.coupon?.id).toEqual('free')
done() done()
it 'Subscribe with prepaid, then cancel', (done) -> it 'Subscribe with prepaid, then cancel', (done) ->
@ -669,7 +677,7 @@ describe 'Subscriptions', ->
user1.save (err, user1) -> user1.save (err, user1) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(user1.isAdmin()).toEqual(true) expect(user1.isAdmin()).toEqual(true)
createPrepaid 'subscription', 1, (err, res, prepaid) -> createPrepaid 'subscription', 1, 0, (err, res, prepaid) ->
expect(err).toBeNull() expect(err).toBeNull()
subscribeUser user1, null, prepaid.code, -> subscribeUser user1, null, prepaid.code, ->
Prepaid.findById prepaid._id, (err, prepaid) -> Prepaid.findById prepaid._id, (err, prepaid) ->
@ -691,7 +699,7 @@ describe 'Subscriptions', ->
user1.save (err, user1) -> user1.save (err, user1) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(user1.isAdmin()).toEqual(true) expect(user1.isAdmin()).toEqual(true)
createPrepaid 'subscription', 1, (err, res, prepaid) -> createPrepaid 'subscription', 1, 0, (err, res, prepaid) ->
expect(err).toBeNull() expect(err).toBeNull()
subscribeUser user1, null, prepaid.code, -> subscribeUser user1, null, prepaid.code, ->
loginNewUser (user2) -> loginNewUser (user2) ->
@ -714,7 +722,7 @@ describe 'Subscriptions', ->
user1.save (err, user1) -> user1.save (err, user1) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(user1.isAdmin()).toEqual(true) expect(user1.isAdmin()).toEqual(true)
createPrepaid 'subscription', 2, (err, res, prepaid) -> createPrepaid 'subscription', 2, 0, (err, res, prepaid) ->
expect(err).toBeNull() expect(err).toBeNull()
subscribeUser user1, null, prepaid.code, -> subscribeUser user1, null, prepaid.code, ->
loginNewUser (user2) -> loginNewUser (user2) ->
@ -730,7 +738,7 @@ describe 'Subscriptions', ->
user1.save (err, user1) -> user1.save (err, user1) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(user1.isAdmin()).toEqual(true) expect(user1.isAdmin()).toEqual(true)
createPrepaid 'subscription', 1, (err, res, prepaid) -> createPrepaid 'subscription', 1, 0, (err, res, prepaid) ->
expect(err).toBeNull() expect(err).toBeNull()
subscribeUser user1, null, prepaid.code, -> subscribeUser user1, null, prepaid.code, ->
Prepaid.findById prepaid._id, (err, prepaid) -> Prepaid.findById prepaid._id, (err, prepaid) ->
@ -746,7 +754,7 @@ describe 'Subscriptions', ->
user1.save (err, user1) -> user1.save (err, user1) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(user1.isAdmin()).toEqual(true) expect(user1.isAdmin()).toEqual(true)
createPrepaid 'subscription', 2, (err, res, prepaid) -> createPrepaid 'subscription', 2, 0, (err, res, prepaid) ->
expect(err).toBeNull() expect(err).toBeNull()
Prepaid.findById prepaid._id, (err, prepaid) -> Prepaid.findById prepaid._id, (err, prepaid) ->
expect(err).toBeNull() expect(err).toBeNull()
@ -795,8 +803,13 @@ describe 'Subscriptions', ->
createNewUser (user2) -> createNewUser (user2) ->
loginNewUser (user1) -> loginNewUser (user1) ->
subscribeRecipients user1, [user2], token, (updatedUser) -> subscribeRecipients user1, [user2], token, (updatedUser) ->
customerID = updatedUser.stripe.customerID expect(updatedUser).not.toBeNull()
subscriptionID = updatedUser.stripe.recipients[0].subscriptionID return done() unless updatedUser
customerID = updatedUser.stripe?.customerID
expect(customerID).toBeDefined()
subscriptionID = updatedUser.stripe?.recipients[0]?.subscriptionID
expect(subscriptionID).toBeDefined()
return done() unless customerID and subscriptionID
loginUser user2, (user2) -> loginUser user2, (user2) ->
request.del {uri: "#{userURL}/#{user2.id}"}, (err, res) -> request.del {uri: "#{userURL}/#{user2.id}"}, (err, res) ->
expect(err).toBeNull() expect(err).toBeNull()
@ -987,7 +1000,7 @@ describe 'Subscriptions', ->
user2.save (err, user1) -> user2.save (err, user1) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(user2.isAdmin()).toEqual(true) expect(user2.isAdmin()).toEqual(true)
createPrepaid 'subscription', 1, (err, res, prepaid) -> createPrepaid 'subscription', 1, 0, (err, res, prepaid) ->
expect(err).toBeNull() expect(err).toBeNull()
requestBody = user2.toObject() requestBody = user2.toObject()
requestBody.stripe = requestBody.stripe =
@ -1022,8 +1035,11 @@ describe 'Subscriptions', ->
createNewUser (user2) -> createNewUser (user2) ->
loginNewUser (user1) -> loginNewUser (user1) ->
subscribeRecipients user1, [user2, user3], token, (updatedUser) -> subscribeRecipients user1, [user2, user3], token, (updatedUser) ->
customerID = updatedUser.stripe.customerID customerID = updatedUser?.stripe?.customerID
subscriptionID = updatedUser.stripe.sponsorSubscriptionID subscriptionID = updatedUser?.stripe?.sponsorSubscriptionID
expect(customerID).toBeDefined()
expect(subscriptionID).toBeDefined()
return done() unless customerID and subscriptionID
# Find Stripe sponsor subscription # Find Stripe sponsor subscription
stripe.customers.retrieveSubscription customerID, subscriptionID, (err, subscription) -> stripe.customers.retrieveSubscription customerID, subscriptionID, (err, subscription) ->
@ -1041,6 +1057,7 @@ describe 'Subscriptions', ->
expect(err).toBeNull() expect(err).toBeNull()
# Should have 2 cancelled recipient subs with cancel_at_period_end = true # Should have 2 cancelled recipient subs with cancel_at_period_end = true
# TODO: is this correct, or do we terminate recipient subs immediately now?
User.findById user1.id, (err, user1) -> User.findById user1.id, (err, user1) ->
expect(err).toBeNull() expect(err).toBeNull()
stripeInfo = user1.get('stripe') stripeInfo = user1.get('stripe')
@ -1159,7 +1176,7 @@ describe 'Subscriptions', ->
user1.save (err, user1) -> user1.save (err, user1) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(user1.isAdmin()).toEqual(true) expect(user1.isAdmin()).toEqual(true)
createPrepaid 'subscription', 1, (err, res, prepaid) -> createPrepaid 'subscription', 1, 0, (err, res, prepaid) ->
expect(err).toBeNull() expect(err).toBeNull()
subscribeUser user1, null, prepaid.code, -> subscribeUser user1, null, prepaid.code, ->
stripe.tokens.create { stripe.tokens.create {
@ -1233,7 +1250,7 @@ describe 'Subscriptions', ->
stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) -> stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(subscription).not.toBeNull() expect(subscription).not.toBeNull()
expect(subscription.quantity).toEqual(getSubscribedQuantity(1)) expect(subscription?.quantity).toEqual(getSubscribedQuantity(1))
# Unsubscribe recipient1 # Unsubscribe recipient1
unsubscribeRecipient user1, recipients.get(1), -> unsubscribeRecipient user1, recipients.get(1), ->
@ -1245,6 +1262,7 @@ describe 'Subscriptions', ->
stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) -> stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(subscription).not.toBeNull() expect(subscription).not.toBeNull()
return done() unless subscription
expect(subscription.quantity).toEqual(0) expect(subscription.quantity).toEqual(0)
done() done()
@ -1274,13 +1292,16 @@ describe 'Subscriptions', ->
unsubscribeRecipient user1, recipients.get(0), -> unsubscribeRecipient user1, recipients.get(0), ->
User.findById user1.id, (err, user1) -> User.findById user1.id, (err, user1) ->
stripeInfo = user1.get('stripe') stripeInfo = user1.get('stripe')
expect(stripeInfo.customerID).toBeDefined()
expect(stripeInfo.sponsorSubscriptionID).toBeDefined()
return done() unless stripeInfo.customerID and stripeInfo.sponsorSubscriptionID
expect(stripeInfo.recipients.length).toEqual(recipientCount - 1) expect(stripeInfo.recipients.length).toEqual(recipientCount - 1)
verifyNotSponsoring user1.id, recipients.get(0).id, -> verifyNotSponsoring user1.id, recipients.get(0).id, ->
verifyNotRecipient recipients.get(0).id, -> verifyNotRecipient recipients.get(0).id, ->
stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) -> stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(subscription).not.toBeNull() expect(subscription).not.toBeNull()
expect(subscription.quantity).toEqual(getSubscribedQuantity(recipientCount - 1)) expect(subscription?.quantity).toEqual(getSubscribedQuantity(recipientCount - 1))
# Unsubscribe second recipient # Unsubscribe second recipient
unsubscribeRecipient user1, recipients.get(1), -> unsubscribeRecipient user1, recipients.get(1), ->
@ -1292,7 +1313,7 @@ describe 'Subscriptions', ->
stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) -> stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(subscription).not.toBeNull() expect(subscription).not.toBeNull()
expect(subscription.quantity).toEqual(getSubscribedQuantity(recipientCount - 2)) expect(subscription?.quantity).toEqual(getSubscribedQuantity(recipientCount - 2))
# Unsubscribe self # Unsubscribe self
User.findById user1.id, (err, user1) -> User.findById user1.id, (err, user1) ->
@ -1312,7 +1333,7 @@ describe 'Subscriptions', ->
stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) -> stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(subscription).not.toBeNull() expect(subscription).not.toBeNull()
expect(subscription.quantity).toEqual(getSubscribedQuantity(recipientCount - 3)) expect(subscription?.quantity).toEqual(getSubscribedQuantity(recipientCount - 3))
done() done()
xit 'Unsubscribed user1 subscribes 13 users, unsubcribes 2', (done) -> xit 'Unsubscribed user1 subscribes 13 users, unsubcribes 2', (done) ->