From c77e1c0fa2108235029356b9f38bfba0c8a256d4 Mon Sep 17 00:00:00 2001 From: Nick Winter Date: Wed, 2 Dec 2015 09:52:52 -0800 Subject: [PATCH] Add course complete visual state for student CourseDetailsView Also including a few misc tweaks to CourseDetailsView and the end-of-course HeroVictoryModal state. --- app/locale/en.coffee | 9 ++ app/styles/courses/course-details.sass | 8 ++ app/templates/courses/course-details.jade | 65 +++++++++-- .../play/level/modal/hero-victory-modal.jade | 13 ++- app/views/courses/CourseDetailsView.coffee | 107 +++++++++++++----- .../play/level/modal/HeroVictoryModal.coffee | 7 +- 6 files changed, 165 insertions(+), 44 deletions(-) diff --git a/app/locale/en.coffee b/app/locale/en.coffee index d4c8a4390..01904884a 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -1360,6 +1360,15 @@ campaigns: "Campaigns" poll: "Poll" user_polls_record: "Poll Voting History" + course: "Course" + courses: "Courses" + course_instance: "Course Instance" + courses_instances: "Course Instances" + classroom: "Classroom" + classrooms: "Classrooms" + clan: "Clan" + clans: "Clans" + members: "Members" concepts: advanced_strings: "Advanced Strings" diff --git a/app/styles/courses/course-details.sass b/app/styles/courses/course-details.sass index aa895e2d1..933e2defa 100644 --- a/app/styles/courses/course-details.sass +++ b/app/styles/courses/course-details.sass @@ -140,3 +140,11 @@ .settings-name-input width: 50% + + .jumbotron + .btn + white-space: normal + min-height: 200px + + h1 + font-size: 48px diff --git a/app/templates/courses/course-details.jade b/app/templates/courses/course-details.jade index 7de3050a4..e32321adb 100644 --- a/app/templates/courses/course-details.jade +++ b/app/templates/courses/course-details.jade @@ -38,7 +38,8 @@ block content if !view.owner.isNew() && view.getOwnerName() span.spl.spr - Teacher: - a(href="/user/#{view.owner.id}") + //a(href="/user/#{view.owner.id}") // Don't link to profiles until we improve them + span strong= view.getOwnerName() h1 @@ -50,11 +51,61 @@ block content if courseInstance.get('description') each line in courseInstance.get('description').split('\n') div= line - + + if view.courseComplete && !view.teacherMode + .jumbotron + .row + if view.singlePlayerMode && !me.isAnonymous() + .col-md-3 + .col-md-6 + a.btn.btn-lg.btn-success(href="/play") + h1 Play the Campaign + p You’re ready to take the next step! Explore hundreds of challenging levels, learn advanced programming skills, and compete in multiplayer arenas! + .col-md-3 + else if view.singlePlayerMode && me.isAnonymous() + .col-md-6 + a.btn.btn-lg.btn-success.signup-button + h1 Create an Account + p Sign up for a FREE CodeCombat account and gain access to more levels, more programming skills, and more fun! + .col-md-6 + a.btn.btn-lg.btn-success(href="/play") + h1 Preview Campaign + p Take a sneak peek at all that CodeCombat has to offer before signing up for your FREE account. + else if !view.singlePlayerMode + .col-md-6 + if view.arenaLevel + a.btn.btn-lg.btn-success.btn-play-level(data-level-slug=view.arenaLevel.slug, data-level-id=view.arenaLevel.original) + h1 + span Arena + | : + span.spl= view.arenaLevel.name + p= view.arenaLevel.description.replace(/!\[.*?\)/, '') + else + a.btn.btn-lg.btn-success.disabled + h1 Arena Coming Soon + p We are working on a multiplayer arena for classrooms at the end of #{course.get('name')}. + .col-md-6 + if view.nextCourseInstance + a.btn.btn-lg.btn-success(href="/courses/#{view.nextCourse.id}/#{view.nextCourseInstance.id}") + h1= view.nextCourse.get('name') + p= view.nextCourse.get('description') + else if view.nextCourse + a.btn.btn-lg.btn-success.disabled + h1= view.nextCourse.get('name') + p + em NOT ENROLLED + p Ask your teacher to enroll you in the next course. + else + a.btn.btn-lg.btn-success(disabled=!view.nextCourse ? "disabled" : "") + h1 Next Course + p + em COMING SOON + p We are hard at work making more courses for you! + if !me.isAnonymous() div.well.well-sm(role='tabpanel') ul.nav.nav-pills(role='tablist') - if adminMode + if view.teacherMode li.active(role='presentation') a(href='#progress', aria-controls='progress', role='tab', data-toggle='tab', data-i18n="courses.progress") li(role='presentation') @@ -65,7 +116,7 @@ block content li(role='presentation') a(href='#progress', aria-controls='progress', role='tab', data-toggle='tab', data-i18n="courses.progress") .tab-content - if adminMode + if view.teacherMode .tab-pane.active#progress(role='tabpanel') +progress-tab .tab-pane#levels(role='tabpanel') @@ -241,7 +292,7 @@ mixin progress-members-popup-completed(i, level, session) p span.spr(data-i18n="courses.completed") span #{moment(session.get('changed')).format('MMMM Do YYYY, h:mm:ss a')} - if adminMode + if view.teacherMode || me.isAdmin() strong(data-i18n="clans.view_solution") mixin progress-members-popup-started(i, level, session) @@ -253,7 +304,7 @@ mixin progress-members-popup-started(i, level, session) p span.spr(data-i18n="clans.last_played") span #{moment(session.get('changed')).format('MMMM Do YYYY, h:mm:ss a')} - if adminMode + if view.teacherMode || me.isAdmin() strong(data-i18n="clans.view_solution") mixin levels-tab @@ -271,7 +322,7 @@ mixin levels-tab each level, levelID in campaign.get('levels') tr td - if lastLevelCompleted || adminMode + if lastLevelCompleted || view.teacherMode - var i18n = level.type === 'course-ladder' ? 'play.compete' : 'home.play'; button.btn.btn-success.btn-play-level(data-level-slug=level.slug, data-i18n=i18n, data-level-id=levelID) td diff --git a/app/templates/play/level/modal/hero-victory-modal.jade b/app/templates/play/level/modal/hero-victory-modal.jade index ba5188fa1..a3b4a24bf 100644 --- a/app/templates/play/level/modal/hero-victory-modal.jade +++ b/app/templates/play/level/modal/hero-victory-modal.jade @@ -12,10 +12,11 @@ block modal-body-content #victory-text= victoryText if isCourseLevel - if currentCourseName - p - span.spr.level-title(data-i18n="play_level.course") - span.level-name= currentCourseName + .course-name-container + if currentCourseName + p + span.spr.level-title(data-i18n="play_level.course") + span.level-name= currentCourseName .container-fluid .row .col-md-6 @@ -26,6 +27,10 @@ block modal-body-content if nextLevelName .level-title(data-i18n="play_level.next_level") .level-name= nextLevelName.replace('Course: ', '') + else + .level-title(data-i18n="play_level.course") + .level-name(data-i18n="play_level.victory_title_suffix") + br #level-feedback diff --git a/app/views/courses/CourseDetailsView.coffee b/app/views/courses/CourseDetailsView.coffee index cfea91cf5..a011b0046 100644 --- a/app/views/courses/CourseDetailsView.coffee +++ b/app/views/courses/CourseDetailsView.coffee @@ -15,6 +15,9 @@ autoplayedOnce = false module.exports = class CourseDetailsView extends RootView id: 'course-details-view' template: template + teacherMode: false + singlePlayerMode: false + memberSort: 'nameAsc' events: 'change .progress-expand-checkbox': 'onCheckExpandedProgress' @@ -31,8 +34,6 @@ module.exports = class CourseDetailsView extends RootView @courseID ?= options.courseID @courseInstanceID ?= options.courseInstanceID @classroom = new Classroom() - @adminMode = me.isAdmin() - @memberSort = 'nameAsc' @course = @supermodel.getModel(Course, @courseID) or new Course _id: @courseID @listenTo @course, 'sync', @onCourseSync @prepaid = new Prepaid() @@ -43,7 +44,6 @@ module.exports = class CourseDetailsView extends RootView getRenderData: -> context = super() - context.adminMode = @adminMode ? false context.campaign = @campaign context.conceptsCompleted = @conceptsCompleted ? {} context.course = @course if @course?.loaded @@ -64,11 +64,19 @@ module.exports = class CourseDetailsView extends RootView context.document = document context + afterRender: -> + super() + if @supermodel.finished() and @courseComplete and me.isAnonymous() and @options.justBeatLevel + # TODO: Make an intermediate modal that tells them they've finished HoC and has some snazzy stuff for convincing players to sign up instead of just throwing up the bare AuthModal + AuthModal = require 'views/core/AuthModal' + @openModalView new AuthModal showSignupRationale: true + onCourseSync: -> + return if @destroyed # console.log 'onCourseSync' if me.isAnonymous() and (not me.get('hourOfCode') and not @course.get('hourOfCode')) @noCourseInstance = true - @render?() + @render() return return if @campaign? campaignID = @course.get('campaignID') @@ -78,24 +86,36 @@ module.exports = class CourseDetailsView extends RootView @onCampaignSync() else @supermodel.loadModel @campaign, 'campaign' - @render?() + @render() onCampaignSync: -> + return if @destroyed # console.log 'onCampaignSync' if @courseInstanceID @loadCourseInstance(@courseInstanceID) else unless me.isAnonymous() - @courseInstances = new CocoCollection([], { url: "/db/user/#{me.id}/course_instances", model: CourseInstance}) - @listenToOnce @courseInstances, 'sync', @onCourseInstancesSync - @supermodel.loadCollection(@courseInstances, 'course_instances') + @loadCourseInstances() @levelConceptMap = {} for levelID, level of @campaign.get('levels') @levelConceptMap[levelID] ?= {} for concept in level.concepts @levelConceptMap[levelID][concept] = true - @render?() + if level.type is 'course-ladder' + @arenaLevel = level + @render() + + loadCourseInstances: -> + @courseInstances = new CocoCollection [], {url: "/db/user/#{me.id}/course_instances", model: CourseInstance, comparator: 'courseID'} + @listenToOnce @courseInstances, 'sync', @onCourseInstancesSync + @supermodel.loadCollection @courseInstances, 'course_instances' + + loadAllCourses: -> + @allCourses = new CocoCollection [], {url: "/db/course", model: Course, comparator: '_id'} + @listenToOnce @allCourses, 'sync', @onAllCoursesSync + @supermodel.loadCollection @allCourses, 'courses' loadCourseInstance: (courseInstanceID) -> + return if @destroyed # console.log 'loadCourseInstance' return if @courseInstance? @courseInstanceID = courseInstanceID @@ -107,23 +127,29 @@ module.exports = class CourseDetailsView extends RootView @courseInstance = @supermodel.loadModel(@courseInstance, 'course_instance').model onCourseInstancesSync: -> + return if @destroyed # console.log 'onCourseInstancesSync' - if @courseInstances.models.length is 1 - @loadCourseInstance(@courseInstances.models[0].id) - else - if @courseInstances.models.length is 0 - @noCourseInstance = true + @findNextCourseInstance() + if not @courseInstance + # We are loading these to find the one we want to display. + if @courseInstances.models.length is 1 + @loadCourseInstance(@courseInstances.models[0].id) else - @noCourseInstanceSelected = true - @render?() + if @courseInstances.models.length is 0 + @noCourseInstance = true + else + @noCourseInstanceSelected = true + @render() onCourseInstanceSync: -> + return if @destroyed # console.log 'onCourseInstanceSync' if @courseInstance.get('classroomID') @classroom = new Classroom({_id: @courseInstance.get('classroomID')}) @supermodel.loadModel @classroom, 'classroom' - @adminMode = true if @courseInstance.get('ownerID') is me.id and @courseInstance.get('name') isnt 'Single Player' - @levelSessions = new CocoCollection([], { url: "/db/course_instance/#{@courseInstance.id}/level_sessions", model: LevelSession, comparator:'_id' }) + @singlePlayerMode = @courseInstance.get('name') is 'Single Player' + @teacherMode = @courseInstance.get('ownerID') is me.id and not @singlePlayerMode + @levelSessions = new CocoCollection([], { url: "/db/course_instance/#{@courseInstance.id}/level_sessions", model: LevelSession, comparator: '_id' }) @listenToOnce @levelSessions, 'sync', @onLevelSessionsSync @supermodel.loadCollection @levelSessions, 'level_sessions', cache: false @members = new CocoCollection([], { url: "/db/course_instance/#{@courseInstance.id}/members", model: User, comparator: 'nameLower' }) @@ -131,19 +157,22 @@ module.exports = class CourseDetailsView extends RootView @supermodel.loadCollection @members, 'members', cache: false @owner = new User({_id: @courseInstance.get('ownerID')}) @supermodel.loadModel @owner, 'user' - if @adminMode and prepaidID = @courseInstance.get('prepaidID') + if @teacherMode and prepaidID = @courseInstance.get('prepaidID') @prepaid = @supermodel.getModel(Prepaid, prepaidID) or new Prepaid _id: prepaidID @listenTo @prepaid, 'sync', @onPrepaidSync if @prepaid.loaded @onPrepaidSync() else @supermodel.loadModel @prepaid, 'prepaid' - @render?() + @render() onPrepaidSync: -> - @render?() + return if @destroyed + # TODO: why do we rerender here? Template doesn't use prepaid. + @render() onLevelSessionsSync: -> + return if @destroyed # console.log 'onLevelSessionsSync' @instanceStats = averageLevelsCompleted: 0, furthestLevelCompleted: '', totalLevelsCompleted: 0, totalPlayTime: 0 @memberStats = {} @@ -197,39 +226,57 @@ module.exports = class CourseDetailsView extends RootView @conceptsCompleted[concept] ?= 0 @conceptsCompleted[concept]++ - if @memberStats[me.id]?.totalLevelsCompleted >= _.size @campaign.get('levels') + if @memberStats[me.id]?.totalLevelsCompleted >= _.size(@campaign.get('levels')) - 1 # Don't need to complete arena @courseComplete = true + @loadCourseInstances() unless @courseInstances # Find the next course instance to do. - @render?() + @render() # If we just joined a single-player course for Hour of Code, we automatically play. - if @instanceStats.totalLevelsCompleted is 0 and @instanceStats.totalPlayTime is 0 and @courseInstance.get('members').length is 1 and me.get('hourOfCode') and not @adminMode and not autoplayedOnce + if @instanceStats.totalLevelsCompleted is 0 and @instanceStats.totalPlayTime is 0 and @singlePlayerMode and not autoplayedOnce autoplayedOnce = true @$el.find('button.btn-play-level').click() onMembersSync: -> + return if @destroyed # console.log 'onMembersSync' @memberUserMap = {} for user in @members.models @memberUserMap[user.id] = user @sortMembers() - @render?() + @render() + + onAllCoursesSync: -> + @findNextCourseInstance() + + findNextCourseInstance: -> + @nextCourseInstance = _.find @courseInstances.models, (ci) => + # Sorted by courseID + ci.get('classroomID') is @courseInstance.get('classroomID') and ci.id isnt @courseInstance.id and ci.get('courseID') > @course.id + if @nextCourseInstance + nextCourseID = @nextCourseInstance.get('courseID') + @nextCourse = @supermodel.getModel(Course, nextCourseID) or new Course _id: nextCourseID + @nextCourse = @supermodel.loadModel(@nextCourse, 'course').model + else if @allCourses?.loaded + @nextCourse = _.find @allCourses.models, (course) => course.id > @course.id + else + @loadAllCourses() onCheckExpandedProgress: (e) -> @showExpandedProgress = $('.progress-expand-checkbox').prop('checked') # TODO: why does render reset the checkbox to be unchecked? - @render?() + @render() $('.progress-expand-checkbox').attr('checked', @showExpandedProgress) onClickMemberHeader: (e) -> @memberSort = if @memberSort is 'nameAsc' then 'nameDesc' else 'nameAsc' @sortMembers() - @render?() + @render() onClickProgressHeader: (e) -> @memberSort = if @memberSort is 'progressAsc' then 'progressDesc' else 'progressAsc' @sortMembers() - @render?() + @render() onClickPlayLevel: (e) -> levelSlug = $(e.target).data('level-slug') @@ -237,7 +284,7 @@ module.exports = class CourseDetailsView extends RootView level = @campaign.get('levels')[levelID] if level.type is 'course-ladder' route = '/play/ladder/' + levelSlug - route += '/course/' + @courseInstance.id if @courseInstance.get('members').length > 1 # No league for solo courses + route += '/course/' + @courseInstance.id unless @singlePlayerMode # No league for solo courses Backbone.Mediator.publish 'router:navigate', route: route else Backbone.Mediator.publish 'router:navigate', { @@ -255,7 +302,7 @@ module.exports = class CourseDetailsView extends RootView @loadCourseInstance(courseInstanceID) onClickProgressLevelCell: (e) -> - return unless @adminMode + return unless @teacherMode or me.isAdmin() levelID = $(e.currentTarget).data('level-id') levelSlug = $(e.currentTarget).data('level-slug') userID = $(e.currentTarget).data('user-id') diff --git a/app/views/play/level/modal/HeroVictoryModal.coffee b/app/views/play/level/modal/HeroVictoryModal.coffee index 6e0170522..c1c82fd25 100644 --- a/app/views/play/level/modal/HeroVictoryModal.coffee +++ b/app/views/play/level/modal/HeroVictoryModal.coffee @@ -63,9 +63,10 @@ module.exports = class HeroVictoryModal extends ModalView else @readyToContinue = true @playSound 'victory' - if @level.get('type', true) is 'course' and nextLevel = @level.get('nextLevel') - @nextLevel = new Level().setURL "/db/level/#{nextLevel.original}/version/#{nextLevel.majorVersion}" - @nextLevel = @supermodel.loadModel(@nextLevel, 'level').model + if @level.get('type', true) is 'course' + if nextLevel = @level.get('nextLevel') + @nextLevel = new Level().setURL "/db/level/#{nextLevel.original}/version/#{nextLevel.majorVersion}" + @nextLevel = @supermodel.loadModel(@nextLevel, 'level').model if @courseID @course = new Course().setURL "/db/course/#{@courseID}" @course = @supermodel.loadModel(@course, 'course').model