From 2c7f7075781cd6d91e89c1f0ef9f703fcc2ddb5b Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Mon, 15 Jun 2026 17:45:30 +0200 Subject: [PATCH 01/27] feat: refresh mobile signup chooser --- .../choose-account-type-view.sass | 175 +++++++----------- .../create-account-modal.sass | 15 ++ .../choose-account-type-view.pug | 35 ++-- 3 files changed, 101 insertions(+), 124 deletions(-) diff --git a/app/styles/modal/create-account-modal/choose-account-type-view.sass b/app/styles/modal/create-account-modal/choose-account-type-view.sass index 61cf70de772..51cfb09a284 100644 --- a/app/styles/modal/create-account-modal/choose-account-type-view.sass +++ b/app/styles/modal/create-account-modal/choose-account-type-view.sass @@ -36,118 +36,83 @@ @if $is-codecombat #choose-account-type-view - width: 1048px - @media (max-width: $screen-md-min) - width: fit-content + width: 100% + + .choose-account-shell + padding: 28px 20px 20px + margin: 0 + gap: 20px + justify-content: flex-start .choose-type-title - display: flex - flex-direction: column - .modal-body-content + margin: 0 + color: $navy + font-size: 34px + font-weight: 700 + line-height: 1.08 + + @media (max-width: 767px) + font-size: 28px + + .choose-type-grid + width: 100% + max-width: 760px + margin: 0 auto + display: grid + grid-template-columns: minmax(0, 1fr) + gap: 20px + + @media (min-width: 768px) + grid-template-columns: repeat(2, minmax(0, 1fr)) + gap: 28px 32px + + .path-option + appearance: none + border: none + background: transparent padding: 0 - margin: 0 50px - - h4 - padding-bottom: 20px - - .path-cards - margin-top: 45px - max-width: 100% - - &:nth-child(2) - margin-top: 17px - @media (max-width: $screen-md-min) - margin-top: 0px - - .path-card - &.navy - border-color: $navy - .card-title - background-color: $navy - - &.forest - border-color: $forest - .card-title - background-color: $forest - - - .card-title - display: flex - flex-direction: column - align-items: center - justify-content: center - height: 50px - max-height: 5vh - color: white - font-weight: bold - text-align: center - - .card-content - flex-grow: 1 - display: flex - flex-direction: column - justify-content: center - margin: 0 20px - - ul - align-self: center - text-align: left - padding-left: 20px - &[dir="rtl"] - text-align: right - li - span - position: relative - left: -5px - - .card-image - width: 300px - height: 200px - background: #C4C4C4 - border-radius: 10px - cursor: pointer - filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.11)) - @media (max-width: $screen-md-min) - display: none - - .card-footer - margin: 0 35px 20px - min-height: 62px - display: flex - flex-direction: column - justify-content: flex-end - span - @media (max-width: $screen-md-min) - order: 2 - &.small div - line-height: 25px - font-weight: 300 - font-size: 18px - white-space: nowrap - margin: 15px -100% - @media (max-width: $screen-md-min) - margin-top: 7px - margin-bottom: 25px - - .primary-oz-button - max-width: 300px - margin: 0px auto - - - .individual-section display: flex - flex-grow: 1 flex-direction: column - align-items: center - justify-content: center - max-width: 425px + gap: 12px + align-items: stretch + text-align: left + + &:hover, + &:focus-visible + text-decoration: none - .individual-title - font-weight: bold + &:focus-visible + outline: 3px solid rgba(118, 90, 255, 0.35) + outline-offset: 6px + border-radius: 28px - .individual-description - margin: 0px 0px 20px 0px + .path-option-image width: 100% + aspect-ratio: 4 / 3 + object-fit: cover + border-radius: 28px + box-shadow: 0 18px 38px rgba(28, 42, 74, 0.16) - .text-h6 + .path-option-cta + display: flex + justify-content: center + align-items: center + min-height: 56px + padding: 14px 18px + border-radius: 14px + background: linear-gradient(135deg, #8A6CFF 0%, #6F5DFF 100%) color: white + font-size: 17px + font-weight: 700 + line-height: 1.2 + text-align: center + + .path-option-description + color: $gray + font-size: 15px + line-height: 1.45 + text-align: center + padding: 0 8px + + @media (max-width: 767px) + font-size: 14px diff --git a/app/styles/modal/create-account-modal/create-account-modal.sass b/app/styles/modal/create-account-modal/create-account-modal.sass index 992b1facb99..1b46ca2d210 100644 --- a/app/styles/modal/create-account-modal/create-account-modal.sass +++ b/app/styles/modal/create-account-modal/create-account-modal.sass @@ -23,6 +23,11 @@ width: 850px max-width: fit-content min-width: 850px + @media (max-width: 767px) + width: calc(100vw - 24px) + max-width: calc(100vw - 24px) + min-width: 0 + margin: 12px auto @media (max-height: 800px) margin-top: 0 @@ -61,6 +66,12 @@ text-align: center padding: 0 border: none + @media (max-width: 767px) + width: 100% + max-width: 100% + min-width: 0 + min-height: auto + max-height: calc(100vh - 24px) // General modal stuff @@ -120,6 +131,10 @@ display: flex align-items: center justify-content: center + @media (max-width: 767px) + min-height: 50px + height: auto + padding: 14px 16px @if $is-codecombat background-color: $navy diff --git a/app/templates/core/create-account-modal/choose-account-type-view.pug b/app/templates/core/create-account-modal/choose-account-type-view.pug index 3f35a704f27..61029fe796f 100644 --- a/app/templates/core/create-account-modal/choose-account-type-view.pug +++ b/app/templates/core/create-account-modal/choose-account-type-view.pug @@ -1,26 +1,23 @@ -mixin button-hello-mivan(type, buttonText, description, imageAlt, imageSRC) - .col-md-6.path-card - img.card-image(class=type data-i18n=imageAlt src=imageSRC) - .card-footer - span.small - div(data-i18n=description) - button.primary-oz-button(class=type) - span(data-i18n=buttonText) +mixin account-type-card(type, buttonText, description, imageAlt, imageSRC) + button.path-option(type="button" class=type) + img.path-option-image(data-i18n=imageAlt src=imageSRC) + span.path-option-cta + span.path-option-label(data-i18n=buttonText) + span.path-option-description(data-i18n=description) if view.utils.isCodeCombat - .modal-body-content + .modal-body-content.choose-account-shell + h2.choose-type-title(data-i18n="nav.create_free_account") if me.showIndividualRegister() - .row.path-cards - +button-hello-mivan("teacher-path-button","new_home.im_an_educator", "new_home.im_an_educator_description", '[alt]new_home.for_educators', '/images/pages/account/create/educator.png') - +button-hello-mivan("parent-path-button","new_home.im_a_parent", "new_home.im_a_parent_description", '[alt]new_home.for_parents', '/images/pages/account/create/parent.png') - - .row.path-cards - +button-hello-mivan("student-path-button","new_home.im_a_student", "new_home.im_a_student_description", '[alt]new_home.for_students', '/images/pages/account/create/student.png') - +button-hello-mivan("individual-path-button","new_home.im_an_individual", "new_home.im_an_individual_description", '[alt]new_home.for_individuals', '/images/pages/account/create/individual.png') + .choose-type-grid + +account-type-card("teacher-path-button","new_home.im_an_educator", "new_home.im_an_educator_description", '[alt]new_home.for_educators', '/images/pages/account/create/educator.png') + +account-type-card("parent-path-button","new_home.im_a_parent", "new_home.im_a_parent_description", '[alt]new_home.for_parents', '/images/pages/account/create/parent.png') + +account-type-card("student-path-button","new_home.im_a_student", "new_home.im_a_student_description", '[alt]new_home.for_students', '/images/pages/account/create/student.png') + +account-type-card("individual-path-button","new_home.im_an_individual", "new_home.im_an_individual_description", '[alt]new_home.for_individuals', '/images/pages/account/create/individual.png') else - .row.path-cards - +button-hello-mivan("teacher-path-button","new_home.im_an_educator", "new_home.im_an_educator_description", '[alt]new_home.for_educators', '/images/pages/account/create/educator.png') - +button-hello-mivan("student-path-button","new_home.im_a_student", "new_home.im_a_student_description", '[alt]new_home.for_students', '/images/pages/account/create/student.png') + .choose-type-grid + +account-type-card("teacher-path-button","new_home.im_an_educator", "new_home.im_an_educator_description", '[alt]new_home.for_educators', '/images/pages/account/create/educator.png') + +account-type-card("student-path-button","new_home.im_a_student", "new_home.im_a_student_description", '[alt]new_home.for_students', '/images/pages/account/create/student.png') else form.modal-body.choose-account-type From c5524c5ab59057caf79dd26c46e74203fb01eaea Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Tue, 16 Jun 2026 10:15:38 +0200 Subject: [PATCH 02/27] feat: refresh individual signup flow slice --- .../create-account-modal/basic-info-view.sass | 71 ++++++ .../create-account-modal.sass | 10 + .../segment-check-view.sass | 73 +++++- .../create-account-modal/basic-info-view.pug | 221 ++++++++++++------ .../segment-check-view.pug | 52 +++-- .../core/CreateAccountModal/BasicInfoView.js | 9 + .../CreateAccountModal/SegmentCheckView.js | 10 + .../CreateAccountModalIndividualFlow.spec.js | 42 ++++ 8 files changed, 387 insertions(+), 101 deletions(-) create mode 100644 test/app/views/core/CreateAccountModalIndividualFlow.spec.js diff --git a/app/styles/modal/create-account-modal/basic-info-view.sass b/app/styles/modal/create-account-modal/basic-info-view.sass index c968e071731..a28067578fe 100644 --- a/app/styles/modal/create-account-modal/basic-info-view.sass +++ b/app/styles/modal/create-account-modal/basic-info-view.sass @@ -2,6 +2,60 @@ @import "ozaria/site/styles/common/variables" #basic-info-view + .individual-flow-panel + width: min(100%, 640px) + padding: 8px 20px 0 + + .individual-flow-progress + display: grid + grid-template-columns: repeat(2, 1fr) + gap: 10px + margin-bottom: 16px + + .individual-flow-progress-step + height: 8px + border-radius: 999px + background: rgb(28 54 87 / 12%) + + &.active + background: linear-gradient(90deg, #f97316 0%, #ea580c 100%) + + .individual-basic-info-card + border-radius: 24px + padding: 28px 24px + background: linear-gradient(180deg, #fffef8 0%, #fff5d1 100%) + box-shadow: 0 20px 50px rgb(15 23 42 / 12%) + .individual-flow-note + margin-bottom: 16px + font-size: 15px + line-height: 1.5 + color: #35506f + + .individual-flow-heading + margin-bottom: 20px + + h4 + margin: 0 + font-size: 28px + line-height: 1.15 + font-weight: 700 + color: #17314d + + .individual-basic-info-fields + display: flex + flex-direction: column + gap: 18px + + .form-group + margin: 0 + + .fancy-error + padding-top: 8px + line-height: 18px + + .individual-flow-social + margin-top: 18px + @if $is-codecombat .network-login transition: filter 0.1s linear @@ -139,3 +193,20 @@ text-align: center padding-bottom: 10px font-weight: lighter + + @media (max-width: 767px) + .individual-flow-panel + padding: 0 16px + + .individual-basic-info-card + padding: 24px 18px + border-radius: 20px + + .individual-flow-heading + h4 + font-size: 24px + + .individual-flow-social + .auth-network-logins + flex-direction: column + gap: 12px diff --git a/app/styles/modal/create-account-modal/create-account-modal.sass b/app/styles/modal/create-account-modal/create-account-modal.sass index 1b46ca2d210..d87eab95358 100644 --- a/app/styles/modal/create-account-modal/create-account-modal.sass +++ b/app/styles/modal/create-account-modal/create-account-modal.sass @@ -162,6 +162,8 @@ align-items: center justify-content: center padding: 20px 0 + @media (max-width: 767px) + padding: 16px 0 // Back/forward buttons @@ -181,6 +183,14 @@ .back-button:first-child:last-child margin-right: auto + @media (max-width: 767px) + flex-direction: column + align-items: stretch + gap: 12px + + .btn + width: 100% + // Forms .form-container diff --git a/app/styles/modal/create-account-modal/segment-check-view.sass b/app/styles/modal/create-account-modal/segment-check-view.sass index 874c05db6e6..0305b18e6bd 100644 --- a/app/styles/modal/create-account-modal/segment-check-view.sass +++ b/app/styles/modal/create-account-modal/segment-check-view.sass @@ -1,4 +1,56 @@ #segment-check-view + .individual-flow-panel + width: min(100%, 560px) + padding: 8px 20px 0 + + .individual-flow-card + border-radius: 24px + padding: 28px 24px + background: linear-gradient(180deg, #fffdf6 0%, #fff7d9 100%) + box-shadow: 0 20px 50px rgb(15 23 42 / 12%) + + .individual-flow-progress + display: grid + grid-template-columns: repeat(2, 1fr) + gap: 10px + margin-bottom: 16px + + .individual-flow-progress-step + height: 8px + border-radius: 999px + background: rgb(28 54 87 / 12%) + + &.active + background: linear-gradient(90deg, #f97316 0%, #ea580c 100%) + + .individual-flow-heading + margin-bottom: 20px + + h4 + margin: 0 0 8px + font-size: 28px + line-height: 1.15 + font-weight: 700 + color: #17314d + + .individual-flow-note + margin-bottom: 16px + font-size: 15px + line-height: 1.5 + color: #35506f + + .individual-birthday-form + margin-bottom: 0 + + .birthday-select-grid + display: grid + grid-template-columns: minmax(0, 1.35fr) minmax(0, 0.85fr) minmax(0, 1fr) + gap: 12px + + select + width: 100% + height: 48px + .class-code-input-group display: flex flex-direction: row @@ -12,8 +64,10 @@ font-size: 30px .parent_birthdate - font-size: 11pt - margin-top: 20px + margin: 0 + font-size: 14px + line-height: 1.5 + color: #4b637c &.student .modal-body-content @@ -97,3 +151,18 @@ & a color: #232323 text-decoration: underline + + @media (max-width: 767px) + .individual-flow-panel + padding: 0 16px + + .individual-flow-card + padding: 24px 18px + border-radius: 20px + + .individual-flow-heading + h4 + font-size: 24px + + .birthday-select-grid + grid-template-columns: 1fr diff --git a/app/templates/core/create-account-modal/basic-info-view.pug b/app/templates/core/create-account-modal/basic-info-view.pug index a50090408cf..c7125c658f6 100644 --- a/app/templates/core/create-account-modal/basic-info-view.pug +++ b/app/templates/core/create-account-modal/basic-info-view.pug @@ -73,64 +73,43 @@ mixin use-social-signon form#basic-info-form.modal-body.basic-info - var path = view.signupState.get('path') .modal-body-content - if !view.utils.isCodeCombat && me.useSocialSignOn() - +use-social-signon - div.form-container - if ['student', 'teacher'].indexOf(view.signupState.get('path')) !== -1 - .row.full-name - .col-xs-offset-3(class=(path === 'teacher' && !me.showChinaRegistration() ? 'col-xs-3' : 'col-xs-5')) - .form-group - label.control-label(for="first-name-input") - span(data-i18n="general.first_name") - input#first-name-input.form-control.input-lg(name="firstName" value=view.signupState.get('signupForm').firstName dir="auto" spellcheck="false") - if !me.showChinaRegistration() - div(class=(path === 'teacher' ? 'col-xs-3' : 'col-xs-4')) - if view.signupState.get('path') === 'teacher' - .last-name.form-group - label.control-label(for="last-name-input") - span(data-i18n="general.last_name") - input#last-name-input.form-control.input-lg(name="lastName" value=view.signupState.get('signupForm').lastName dir="auto" spellcheck="false") - else - .last-initial.form-group - label.control-label(for="last-name-input") - span(data-i18n="general.last_initial") - input#last-name-input.form-control.input-lg(name="lastName" maxlength="1" value=view.signupState.get('signupForm').lastName dir="auto" spellcheck="false") - .form-group - .row.rtl-allowed - .col-xs-5.col-xs-offset-3.rtl-allowed + if path === 'individual' + .individual-flow-panel + .individual-flow-progress(aria-hidden="true") + .individual-flow-progress-step + .individual-flow-progress-step.active + .individual-flow-card.individual-basic-info-card + if view.signupState.get('subModalContinue') + .individual-flow-note(data-i18n="signup.sign_in_to_continue") + .individual-flow-heading + h4(data-i18n="signup.create_individual_header") + .individual-basic-info-fields if view.utils.isOzaria || !view.hideEmail - label.control-label(for="email-input") - span(data-i18n="share_progress_modal.form_label") - if view.signupState.get('path') === 'student' - .help-block.optional-help-block.pull-right - span.optional-text(data-i18n="signup.optional") - input.form-control.input-lg#email-input(name="email" type="email" value=view.signupState.get('signupForm').email dir="auto") - .col-xs-4.email-check.fancy-error.rtl-allowed - - var checkEmailState = view.state.get('checkEmailState'); - if checkEmailState === 'checking' - span.small(data-i18n="signup.checking") - if checkEmailState === 'exists' - span.small - span.text-burgundy.glyphicon.glyphicon-remove-circle - =" " - span(data-i18n="signup.account_exists") - =" " - a.login-link(data-i18n="signup.sign_in") - - if checkEmailState === 'available' - span.small - span.text-forest.glyphicon.glyphicon-ok-circle - =" " - span(data-i18n="signup.email_good") - - if view.signupState.get('path') !== 'teacher' - if view.showLibraryIdInsteadOfUsername - .form-group - .row.library-signup-text - span(data-i18n="library.library_signup_promotion") - .form-group - .row.rtl-allowed - .col-xs-5.col-xs-offset-3.rtl-allowed + .form-group + label.control-label(for="email-input") + span(data-i18n="share_progress_modal.form_label") + input.form-control.input-lg#email-input(name="email" type="email" value=view.signupState.get('signupForm').email dir="auto") + .email-check.fancy-error.rtl-allowed + - var checkEmailState = view.state.get('checkEmailState'); + if checkEmailState === 'checking' + span.small(data-i18n="signup.checking") + if checkEmailState === 'exists' + span.small + span.text-burgundy.glyphicon.glyphicon-remove-circle + =" " + span(data-i18n="signup.account_exists") + =" " + a.login-link(data-i18n="signup.sign_in") + if checkEmailState === 'available' + span.small + span.text-forest.glyphicon.glyphicon-ok-circle + =" " + span(data-i18n="signup.email_good") + if view.showLibraryIdInsteadOfUsername + .form-group + .row.library-signup-text + span(data-i18n="library.library_signup_promotion") + .form-group if view.showLibraryIdInsteadOfUsername label.control-label(for="username-input") span(data-i18n="library.library_card") @@ -138,33 +117,125 @@ form#basic-info-form.modal-body.basic-info label.control-label(for="username-input") span(data-i18n="general.username") input.form-control.input-lg#username-input(name="name" value=view.signupState.get('signupForm').name dir="auto" spellcheck="false") - .col-xs-4.name-check.fancy-error.rtl-allowed - - var checkNameState = view.state.get('checkNameState'); - if checkNameState === 'checking' + .name-check.fancy-error.rtl-allowed + - var checkNameState = view.state.get('checkNameState'); + if checkNameState === 'checking' + span.small(data-i18n="signup.checking") + if checkNameState === 'exists' + span.small + span.text-burgundy.glyphicon.glyphicon-remove-circle + =" " + span= view.state.get('suggestedNameText') + if checkNameState === 'available' + span.small + span.text-forest.glyphicon.glyphicon-ok-circle + =" " + span(data-i18n="signup.name_available") + .form-group + label.control-label(for="password-input") + span(data-i18n="general.password") + input.form-control.input-lg#password-input(name="password" type="password" value=view.signupState.get('signupForm').password dir="auto") + small.form-text.text-muted(data-i18n="signup.password_requirements") + if view.utils.isCodeCombat + .individual-flow-social + +use-social-signon + +error-area + else if me.useSocialSignOn() + +use-social-signon + +error-area + else + if !view.utils.isCodeCombat && me.useSocialSignOn() + +use-social-signon + div.form-container + if ['student', 'teacher'].indexOf(view.signupState.get('path')) !== -1 + .row.full-name + .col-xs-offset-3(class=(path === 'teacher' && !me.showChinaRegistration() ? 'col-xs-3' : 'col-xs-5')) + .form-group + label.control-label(for="first-name-input") + span(data-i18n="general.first_name") + input#first-name-input.form-control.input-lg(name="firstName" value=view.signupState.get('signupForm').firstName dir="auto" spellcheck="false") + if !me.showChinaRegistration() + div(class=(path === 'teacher' ? 'col-xs-3' : 'col-xs-4')) + if view.signupState.get('path') === 'teacher' + .last-name.form-group + label.control-label(for="last-name-input") + span(data-i18n="general.last_name") + input#last-name-input.form-control.input-lg(name="lastName" value=view.signupState.get('signupForm').lastName dir="auto" spellcheck="false") + else + .last-initial.form-group + label.control-label(for="last-name-input") + span(data-i18n="general.last_initial") + input#last-name-input.form-control.input-lg(name="lastName" maxlength="1" value=view.signupState.get('signupForm').lastName dir="auto" spellcheck="false") + .form-group + .row.rtl-allowed + .col-xs-5.col-xs-offset-3.rtl-allowed + if view.utils.isOzaria || !view.hideEmail + label.control-label(for="email-input") + span(data-i18n="share_progress_modal.form_label") + if view.signupState.get('path') === 'student' + .help-block.optional-help-block.pull-right + span.optional-text(data-i18n="signup.optional") + input.form-control.input-lg#email-input(name="email" type="email" value=view.signupState.get('signupForm').email dir="auto") + .col-xs-4.email-check.fancy-error.rtl-allowed + - var checkEmailState = view.state.get('checkEmailState'); + if checkEmailState === 'checking' span.small(data-i18n="signup.checking") - if checkNameState === 'exists' + if checkEmailState === 'exists' span.small span.text-burgundy.glyphicon.glyphicon-remove-circle =" " - span= view.state.get('suggestedNameText') - if checkNameState === 'available' + span(data-i18n="signup.account_exists") + =" " + a.login-link(data-i18n="signup.sign_in") + + if checkEmailState === 'available' span.small span.text-forest.glyphicon.glyphicon-ok-circle =" " - span(data-i18n="signup.name_available") + span(data-i18n="signup.email_good") + + if view.signupState.get('path') !== 'teacher' + if view.showLibraryIdInsteadOfUsername + .form-group + .row.library-signup-text + span(data-i18n="library.library_signup_promotion") + .form-group + .row.rtl-allowed + .col-xs-5.col-xs-offset-3.rtl-allowed + if view.showLibraryIdInsteadOfUsername + label.control-label(for="username-input") + span(data-i18n="library.library_card") + else + label.control-label(for="username-input") + span(data-i18n="general.username") + input.form-control.input-lg#username-input(name="name" value=view.signupState.get('signupForm').name dir="auto" spellcheck="false") + .col-xs-4.name-check.fancy-error.rtl-allowed + - var checkNameState = view.state.get('checkNameState'); + if checkNameState === 'checking' + span.small(data-i18n="signup.checking") + if checkNameState === 'exists' + span.small + span.text-burgundy.glyphicon.glyphicon-remove-circle + =" " + span= view.state.get('suggestedNameText') + if checkNameState === 'available' + span.small + span.text-forest.glyphicon.glyphicon-ok-circle + =" " + span(data-i18n="signup.name_available") - .form-group - .row.rtl-allowed - .col-xs-5.col-xs-offset-3.rtl-allowed - label.control-label(for="password-input") - span(data-i18n="general.password") - input.form-control.input-lg#password-input(name="password" type="password" value=view.signupState.get('signupForm').password dir="auto") - small.form-text.text-muted(data-i18n="signup.password_requirements") + .form-group + .row.rtl-allowed + .col-xs-5.col-xs-offset-3.rtl-allowed + label.control-label(for="password-input") + span(data-i18n="general.password") + input.form-control.input-lg#password-input(name="password" type="password" value=view.signupState.get('signupForm').password dir="auto") + small.form-text.text-muted(data-i18n="signup.password_requirements") - if view.utils.isCodeCombat - +error-area - if view.utils.isCodeCombat && me.useSocialSignOn() - +use-social-signon + if view.utils.isCodeCombat + +error-area + if view.utils.isCodeCombat && me.useSocialSignOn() + +use-social-signon if view.utils.isOzaria +error-area diff --git a/app/templates/core/create-account-modal/segment-check-view.pug b/app/templates/core/create-account-modal/segment-check-view.pug index d14833ea6c4..d8ca22e5d89 100644 --- a/app/templates/core/create-account-modal/segment-check-view.pug +++ b/app/templates/core/create-account-modal/segment-check-view.pug @@ -83,30 +83,34 @@ form.modal-body.segment-check when 'teacher' // TODO when 'individual' - if view.signupState.get('subModalContinue') - span(data-i18n="signup.sign_in_to_continue") - br - else if view.utils.isCodeCombat && view.signupState.get('accountRequiredMessage') - span= view.signupState.get('accountRequiredMessage') - br - .birthday-form-group.form-group - span(data-i18n="signup.enter_birthdate") - .input-border - select#birthday-month-input.input-large.form-control(name="birthdayMonth", style="width: 106px; float: left") - option(value='',data-i18n="calendar.month") - for name, index in ['january','february','march','april','may','june','july','august','september','october','november','december'] - - var month = index + 1 - option(data-i18n=('calendar.' + name) value=month, selected=(month === view.signupState.get('birthdayMonth'))) - select#birthday-day-input.input-large.form-control(name="birthdayDay", style="width: 75px; float: left") - option(value='',data-i18n="calendar.day") - for day in _.range(1,32) - option(selected=(day === view.signupState.get('birthdayDay'))) #{day} - select#birthday-year-input.input-large.form-control(name="birthdayYear", style="width: 90px; float: left") - option(value='',data-i18n="calendar.year") - - var thisYear = new Date().getFullYear() - for year in _.range(thisYear, thisYear - 100, -1) - option(selected=(year === view.signupState.get('birthdayYear'))) #{year} - .parent_birthdate(data-i18n="signup.parent_use_birthdate") + .individual-flow-panel + .individual-flow-progress(aria-hidden="true") + .individual-flow-progress-step.active + .individual-flow-progress-step + .individual-flow-card + if view.signupState.get('subModalContinue') + .individual-flow-note(data-i18n="signup.sign_in_to_continue") + else if view.utils.isCodeCombat && view.signupState.get('accountRequiredMessage') + .individual-flow-note= view.signupState.get('accountRequiredMessage') + .individual-flow-heading + h4(data-i18n="signup.enter_birthdate") + p.parent_birthdate(data-i18n="signup.parent_use_birthdate") + .birthday-form-group.form-group.individual-birthday-form + .birthday-select-grid + select#birthday-month-input.input-large.form-control(name="birthdayMonth") + option(value='',data-i18n="calendar.month") + for name, index in ['january','february','march','april','may','june','july','august','september','october','november','december'] + - var month = index + 1 + option(data-i18n=('calendar.' + name) value=month, selected=(month === view.signupState.get('birthdayMonth'))) + select#birthday-day-input.input-large.form-control(name="birthdayDay") + option(value='',data-i18n="calendar.day") + for day in _.range(1,32) + option(selected=(day === view.signupState.get('birthdayDay'))) #{day} + select#birthday-year-input.input-large.form-control(name="birthdayYear") + option(value='',data-i18n="calendar.year") + - var thisYear = new Date().getFullYear() + for year in _.range(thisYear, thisYear - 100, -1) + option(selected=(year === view.signupState.get('birthdayYear'))) #{year} default p(class=view.utils.isOzaria ? "medium-gray" : '') diff --git a/app/views/core/CreateAccountModal/BasicInfoView.js b/app/views/core/CreateAccountModal/BasicInfoView.js index 6fde9f31985..24c6ee2d2b8 100644 --- a/app/views/core/CreateAccountModal/BasicInfoView.js +++ b/app/views/core/CreateAccountModal/BasicInfoView.js @@ -354,6 +354,14 @@ module.exports = (BasicInfoView = (function () { return forms.clearFormAlerts(this.$el.find('input[name="name"]').closest('.form-group').parent()) } + trackIndividualStepNext (action) { + if (this.signupState.get('path') !== 'individual') { return } + return window.tracker?.trackEvent('CreateAccountModal Individual Step 2 Next Clicked', { + category: 'Individuals', + action, + }) + } + onSubmitForm (e) { if (this.signupState.get('path') === 'teacher') { if (window.tracker != null) { @@ -366,6 +374,7 @@ module.exports = (BasicInfoView = (function () { } } if (this.signupState.get('path') === 'individual') { + this.trackIndividualStepNext('submit-clicked') if (window.tracker != null) { window.tracker.trackEvent('CreateAccountModal Individual BasicInfoView Submit Clicked', { category: 'Individuals' }) } diff --git a/app/views/core/CreateAccountModal/SegmentCheckView.js b/app/views/core/CreateAccountModal/SegmentCheckView.js index 7d0806194f2..af2e9db9a88 100644 --- a/app/views/core/CreateAccountModal/SegmentCheckView.js +++ b/app/views/core/CreateAccountModal/SegmentCheckView.js @@ -153,6 +153,14 @@ module.exports = (SegmentCheckView = (function () { } } + trackIndividualStepNext (destination) { + if (this.signupState.get('path') !== 'individual') { return } + return window.tracker?.trackEvent('CreateAccountModal Individual Step 1 Next Clicked', { + category: 'Individuals', + destination, + }) + } + onSubmitSegmentCheck (e) { e.preventDefault() @@ -191,9 +199,11 @@ module.exports = (SegmentCheckView = (function () { const age = (new Date().getTime() - this.signupState.get('birthday').getTime()) / 365.4 / 24 / 60 / 60 / 1000 if (age > utils.ageOfConsent(me.get('country'), 13)) { const screen = me.get('country') && me.inEU() ? 'eu-confirmation' : 'basic-info' + this.trackIndividualStepNext(screen) this.trigger('nav-forward', screen) return (window.tracker != null ? window.tracker.trackEvent('CreateAccountModal Individual SegmentCheckView Submit', { category: 'Individuals' }) : undefined) } else { + this.trackIndividualStepNext('coppa-deny') this.trigger('nav-forward', 'coppa-deny') return (window.tracker != null ? window.tracker.trackEvent('CreateAccountModal Individual SegmentCheckView Coppa Deny', { category: 'Individuals' }) : undefined) } diff --git a/test/app/views/core/CreateAccountModalIndividualFlow.spec.js b/test/app/views/core/CreateAccountModalIndividualFlow.spec.js new file mode 100644 index 00000000000..62aac1e4e13 --- /dev/null +++ b/test/app/views/core/CreateAccountModalIndividualFlow.spec.js @@ -0,0 +1,42 @@ +const SegmentCheckView = require('views/core/CreateAccountModal/SegmentCheckView') +const BasicInfoView = require('views/core/CreateAccountModal/BasicInfoView') +const State = require('models/State') + +describe('CreateAccountModal individual flow tracking', function () { + let originalTracker + + beforeEach(function () { + originalTracker = window.tracker + window.tracker = { + trackEvent: jasmine.createSpy('trackEvent'), + } + }) + + afterEach(function () { + window.tracker = originalTracker + }) + + it('tracks the individual age-gate next action with destination', function () { + const signupState = new State({ path: 'individual' }) + const view = new SegmentCheckView({ signupState }) + + view.trackIndividualStepNext('basic-info') + + expect(window.tracker.trackEvent).toHaveBeenCalledWith( + 'CreateAccountModal Individual Step 1 Next Clicked', + { category: 'Individuals', destination: 'basic-info' }, + ) + }) + + it('tracks the individual basic-info next action with submit state', function () { + const signupState = new State({ path: 'individual' }) + const view = new BasicInfoView({ signupState }) + + view.trackIndividualStepNext('submit-clicked') + + expect(window.tracker.trackEvent).toHaveBeenCalledWith( + 'CreateAccountModal Individual Step 2 Next Clicked', + { category: 'Individuals', action: 'submit-clicked' }, + ) + }) +}) From b8320780968e3fe77461bf089d6238795b17697b Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Wed, 17 Jun 2026 21:19:04 +0200 Subject: [PATCH 03/27] fix(signup): stack social-login buttons on mobile for all account paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .auth-network-logins row used flex-direction:row with fixed-width Google (254px) and Clever (154px) buttons. On mobile this overflowed the modal on both edges for every path except individual (which had its own scoped rule). Worst-hit is the teacher signup reached from /schools — the primary surface for school/district sales leads, who are mostly on mobile. Stack the row vertically below 767px and constrain the fixed-width logos to full width. Verified live at mobile width on the teacher modal. Co-Authored-By: Claude Opus 4.8 --- .../create-account-modal/basic-info-view.sass | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/app/styles/modal/create-account-modal/basic-info-view.sass b/app/styles/modal/create-account-modal/basic-info-view.sass index a28067578fe..851088fcd4a 100644 --- a/app/styles/modal/create-account-modal/basic-info-view.sass +++ b/app/styles/modal/create-account-modal/basic-info-view.sass @@ -206,7 +206,24 @@ h4 font-size: 24px - .individual-flow-social - .auth-network-logins - flex-direction: column - gap: 12px + // Stack the social-login row on mobile for every path (teacher/parent/ + // student/individual), not just the individual flow. The Google and Clever + // buttons have fixed pixel widths that otherwise overflow the modal. + .auth-network-logins + flex-direction: column + gap: 12px + width: 100% + + .network-login, + .gplus-login, + .clever-login, + .facebook-login, + .schoology-login, + .classlink-login + width: 100% + max-width: 100% + + .gplus-logo, + .clever-logo + width: 100% + max-width: 320px From e88c5505388dd37485454f52d04242d320584f29 Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Wed, 17 Jun 2026 21:39:01 +0200 Subject: [PATCH 04/27] fix(signup): stop modal overflowing viewport width on mobile Two mobile overflow sources in the teacher/student create-account modal: - .form-container had a hard width:800px (desktop layout) that forced ~177px of horizontal overflow inside the modal on phones, producing a viewport horizontal scrollbar and letting the whole modal scroll out of bounds. Constrain it to 100% below 767px. - The Clever logo was being stretched to full column width, standing out as an oversized bare logo. Keep it at natural size, centred. Verified live at mobile width: modal scrollWidth now equals clientWidth (no horizontal scroll); Clever renders at its natural ~162px. Co-Authored-By: Claude Opus 4.8 --- .../create-account-modal/basic-info-view.sass | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/app/styles/modal/create-account-modal/basic-info-view.sass b/app/styles/modal/create-account-modal/basic-info-view.sass index 851088fcd4a..e2c620b452d 100644 --- a/app/styles/modal/create-account-modal/basic-info-view.sass +++ b/app/styles/modal/create-account-modal/basic-info-view.sass @@ -223,7 +223,25 @@ width: 100% max-width: 100% - .gplus-logo, - .clever-logo + // Google's button reads well full-width; constrain it so it never + // exceeds the column. + .gplus-logo width: 100% max-width: 320px + + // Keep Clever's fixed-size logo at its natural size and centre it in the + // full-width row, rather than stretching the bare logo into a sore thumb. + .clever-login + display: flex + justify-content: center + + .clever-logo + width: auto + max-width: 200px + + // The teacher/student form sets an 800px desktop width that forced ~177px + // of horizontal overflow on mobile (and a viewport scrollbar). Let it fill + // the modal instead. + .form-container + width: 100% + max-width: 100% From 68e4c32d48a5bc35546831c19bb37b6599ab6894 Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Wed, 17 Jun 2026 22:07:29 +0200 Subject: [PATCH 05/27] feat(signup): mobile two-step SSO-first signup modal with logo refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On mobile (<=767px) the Create Account modal now leads with the sign-in integrations and collapses the email/name/password form behind a de-emphasized "Sign up with email" link, matching the recognizable progressive-disclosure pattern a first-time lead expects on a phone. Choosing email reveals the form + Next and focuses the first field; an "Other sign-in options" link returns to the chooser. The two-step behavior is gated on a `has-sso` class added in afterRender, so library / no-SSO paths keep the single-screen form. The collapse logic is scoped under the mobile media query, leaving desktop's single screen intact. Refreshes the SSO chooser into one coherent set on every width: a single "Sign up with" header over a vertical stack of identical rows that each center the provider's own brand logo (no per-row "Continue with X" text). Swaps the mismatched icons/buttons for width-focused brand lockups — Google and Facebook wordmarks and the Schoology wordmark are added as SVGs; Clever and ClassLink reuse their existing wordmarks. Google no longer renders the GSI library's outline button (which leaked the browser locale and looked non-standard); it is now a normal centered "Google" logo row wired to the same gplusHandler.connect() One Tap flow the login modal already uses. Removes the "Back" control from this view on mobile — closing the modal or the "Sign in" link cover that. Shared across the teacher (sales), student, and individual signup paths. Verified live (mobile + desktop) for the teacher and individual flows: chooser, logo loading, email reveal/return, Google sign-in trigger, and desktop single-screen no-op. Co-Authored-By: Claude Opus 4.8 --- .../modal/auth/facebook-logo-wordmark.svg | 1 + .../pages/modal/auth/google-logo-wordmark.svg | 1 + .../modal/auth/schoology-logo-wordmark.svg | 34 +++ app/locale/en.js | 2 + .../create-account-modal/basic-info-view.sass | 247 ++++++++++++------ .../create-account-modal/basic-info-view.pug | 40 +-- .../core/CreateAccountModal/BasicInfoView.js | 47 +++- 7 files changed, 273 insertions(+), 99 deletions(-) create mode 100644 app/assets/images/pages/modal/auth/facebook-logo-wordmark.svg create mode 100644 app/assets/images/pages/modal/auth/google-logo-wordmark.svg create mode 100644 app/assets/images/pages/modal/auth/schoology-logo-wordmark.svg diff --git a/app/assets/images/pages/modal/auth/facebook-logo-wordmark.svg b/app/assets/images/pages/modal/auth/facebook-logo-wordmark.svg new file mode 100644 index 00000000000..ca498f6eea1 --- /dev/null +++ b/app/assets/images/pages/modal/auth/facebook-logo-wordmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/pages/modal/auth/google-logo-wordmark.svg b/app/assets/images/pages/modal/auth/google-logo-wordmark.svg new file mode 100644 index 00000000000..d316b5d50ad --- /dev/null +++ b/app/assets/images/pages/modal/auth/google-logo-wordmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/pages/modal/auth/schoology-logo-wordmark.svg b/app/assets/images/pages/modal/auth/schoology-logo-wordmark.svg new file mode 100644 index 00000000000..5ecfcbb9239 --- /dev/null +++ b/app/assets/images/pages/modal/auth/schoology-logo-wordmark.svg @@ -0,0 +1,34 @@ + + + diff --git a/app/locale/en.js b/app/locale/en.js index 1d78f8d3984..57d1501d312 100644 --- a/app/locale/en.js +++ b/app/locale/en.js @@ -809,6 +809,8 @@ module.exports = { educator_signup_list_3_title: 'See your students thrive:', educator_signup_list_3_desc: 'Try the first unit with your class for free', signup_with_email: 'Sign up with Email', + sign_up_with: 'Sign up with', + other_signin_options: '← Other sign-in options', already_have_account: 'Already have a CodeCombat or Ozaria account', select_your_role: 'Select your role', select_your_state: 'Select your state', diff --git a/app/styles/modal/create-account-modal/basic-info-view.sass b/app/styles/modal/create-account-modal/basic-info-view.sass index e2c620b452d..c1cf4ceaba8 100644 --- a/app/styles/modal/create-account-modal/basic-info-view.sass +++ b/app/styles/modal/create-account-modal/basic-info-view.sass @@ -63,60 +63,97 @@ &:hover filter: brightness(110%) - .auth-network-logins - display: flex - align-items: center - justify-content: center - flex-direction: row - gap: 20px - - .gplus-login - @if $is-codecombat - - .gplus-logo - @if $is-codecombat - width: 254px - @else - height: 50px - margin-right: 7px + @if $is-codecombat + // One header labels the whole set ("Sign up with"); each row below simply + // centers the provider's own logo and lets it carry its own recognition. + .sso-header + margin-bottom: 12px + text-align: center + font-size: 13px + font-weight: 700 + letter-spacing: 0.04em + text-transform: uppercase + color: #5a6b7b - .facebook-login + .auth-network-logins + display: flex + flex-direction: column + align-items: stretch + gap: 12px - #facebook-signup-btn - height: 40px + // Identical option rows; the provider logo is centered and stands alone. + .sso-row + display: flex + align-items: center + justify-content: center + width: 100% + min-height: 52px + padding: 0 16px + border: 1px solid #d7dee6 + border-radius: 8px + background: white + text-decoration: none + cursor: pointer + transition: border-color 0.1s linear, box-shadow 0.1s linear + + &:hover + border-color: #b8c4d0 + box-shadow: 0 1px 4px rgb(15 23 42 / 8%) + + // Centered provider logo. Width-focused brand lockups, scaled by a shared + // height so each reads at consistent visual weight; per-provider nudges + // account for differing logo padding/baselines. + .sso-logo + width: auto + height: 24px + max-width: 78% + object-fit: contain + + // Google's wordmark is light/thin; give it a little more height to balance + // against the bolder Clever and ClassLink marks. + .sso-logo-google + height: 28px - .facebook-logo - height: 40px + .sso-logo-clever + height: 20px - .schoology-login,.classlink-login - #schoology-signup-btn,#classlink-signup-btn - padding: 4px - display: inline-flex - gap: 5px - justify-content: initial + .sso-logo-classlink + height: 24px + + .sso-logo-schoology + height: 22px + + @if not $is-codecombat + .auth-network-logins + display: flex align-items: center - border-radius: 5px - height: 45px - width: 100% - border: 1px solid #117bbc - color: black - background-color: white - font-size: 14px - img - width: 38px - height: 38px - border-radius: 50% + justify-content: center + flex-direction: row + gap: 20px - @if $is-codecombat - .clever-login - - .clever-logo - // Match size of Google button - width: 154px - height: 52px - background: white - padding: 5px + .gplus-login + .gplus-logo + height: 50px + margin-right: 7px + + .schoology-login,.classlink-login + #schoology-signup-btn,#classlink-signup-btn + padding: 4px + display: inline-flex + gap: 5px + justify-content: initial + align-items: center + border-radius: 5px + height: 45px + width: 100% border: 1px solid #117bbc + color: black + background-color: white + font-size: 14px + img + width: 38px + height: 38px + border-radius: 50% // Forms @@ -194,6 +231,42 @@ padding-bottom: 10px font-weight: lighter + // Mobile two-step SSO/email disclosure controls. Hidden by default (desktop + // and any non-mobile view); only revealed inside the mobile media query below. + // The email option is a de-emphasized text link, not a button, so the SSO + // integrations stay the primary path. + .reveal-email-row + display: none + width: 100% + margin-top: 18px + padding: 4px + background: none + border: none + text-align: center + color: #5a6b7b + font-size: 14px + font-weight: 500 + text-decoration: underline + cursor: pointer + + &:hover, + &:focus + color: #2b3a4a + + .back-to-sso-row + display: none + margin-bottom: 14px + padding: 0 + background: none + border: none + color: #117bbc + font-weight: 600 + cursor: pointer + + &:hover, + &:focus + text-decoration: underline + @media (max-width: 767px) .individual-flow-panel padding: 0 16px @@ -206,38 +279,24 @@ h4 font-size: 24px - // Stack the social-login row on mobile for every path (teacher/parent/ - // student/individual), not just the individual flow. The Google and Clever - // buttons have fixed pixel widths that otherwise overflow the modal. - .auth-network-logins - flex-direction: column - gap: 12px - width: 100% - - .network-login, - .gplus-login, - .clever-login, - .facebook-login, - .schoology-login, - .classlink-login + // Ozaria's social buttons still use fixed-width brand images; stack them on + // mobile so they don't overflow. CodeCombat already stacks as uniform + // .sso-row items, so it needs no mobile-specific social rules here. + @if not $is-codecombat + .auth-network-logins + flex-direction: column + gap: 12px width: 100% - max-width: 100% - // Google's button reads well full-width; constrain it so it never - // exceeds the column. - .gplus-logo - width: 100% - max-width: 320px + .network-login, + .gplus-login, + .classlink-login + width: 100% + max-width: 100% - // Keep Clever's fixed-size logo at its natural size and centre it in the - // full-width row, rather than stretching the bare logo into a sore thumb. - .clever-login - display: flex - justify-content: center - - .clever-logo - width: auto - max-width: 200px + .gplus-logo + width: 100% + max-width: 320px // The teacher/student form sets an 800px desktop width that forced ~177px // of horizontal overflow on mobile (and a viewport scrollbar). Let it fill @@ -245,3 +304,39 @@ .form-container width: 100% max-width: 100% + + // "Back" doesn't belong in the first-impression chooser — the user can + // close the modal or switch to "Sign in" instead. + .back-button + display: none + + // Two-step progressive disclosure, only when SSO options are present + // (gated by `has-sso`, added in afterRender). Library / no-SSO paths keep + // the single-screen form untouched. + &.has-sso + // Step 1 — SSO chooser front and center; the email form + submit are + // collapsed behind the "Sign up with email" action. + &:not(.email-revealed) + .individual-basic-info-card, + .form-container, + .next-button + display: none + + // The "or" divider belongs between the form and SSO; with SSO first + // and the form collapsed it would float at the top with nothing above. + .hr-text + display: none + + .reveal-email-row + display: block + + // Step 2 — email form revealed; hide the SSO chooser and surface a way + // back to the options. + &.email-revealed + .hr-text, + .auth-network-logins, + .reveal-email-row + display: none + + .back-to-sso-row + display: inline-block diff --git a/app/templates/core/create-account-modal/basic-info-view.pug b/app/templates/core/create-account-modal/basic-info-view.pug index c7125c658f6..75ca285d65f 100644 --- a/app/templates/core/create-account-modal/basic-info-view.pug +++ b/app/templates/core/create-account-modal/basic-info-view.pug @@ -13,27 +13,28 @@ mixin use-social-signon hr span(data-i18n="general.or") + //- One shared header; each row below just centers the provider's own + //- logo and lets it carry its own recognition. + .sso-header(data-i18n="signup.sign_up_with") + .auth-network-logins.text-center - .gplus-login#google-login-button - a.network-login(disabled=!view.signupState.get('gplusEnabled'), data-sso-used="gplus") - img.gplus-logo(src="/images/pages/modal/auth/gplus_sso_button2.svg", draggable="false") + a#google-signup-btn.network-login.sso-row(disabled=!view.signupState.get('gplusEnabled'), data-sso-used="gplus") + img.sso-logo.sso-logo-google(src="/images/pages/modal/auth/google-logo-wordmark.svg", draggable="false") if !/(teacher|student)/.test(view.signupState.get('path')) - .facebook-login - a#facebook-signup-btn.network-login(disabled=!view.signupState.get('facebookEnabled'), data-sso-used="facebook") - img.facebook-logo(src="/images/pages/modal/auth/facebook_sso_button2.png", draggable="false") - if /(teacher|student)/.test(view.signupState.get('path')) - .clever-login - a#clever-signup-btn.network-login(data-sso-used="clever") - img.clever-logo(src="/images/pages/modal/auth/clever-logo-blue.png", draggable="false") + a#facebook-signup-btn.network-login.sso-row(disabled=!view.signupState.get('facebookEnabled'), data-sso-used="facebook") + img.sso-logo.sso-logo-facebook(src="/images/pages/modal/auth/facebook-logo-wordmark.svg", draggable="false") if /(teacher|student)/.test(view.signupState.get('path')) - .schoology-login - a#schoology-signup-btn.network-login(data-sso-used="schoology") - img.schoology-login-img(src="/images/pages/modal/auth/schoology.png") - span Schoology - .classlink-login - a#classlink-signup-btn.network-login(data-sso-used="classlink") - img.classlink-login-img(src="/images/pages/modal/auth/classlink-logo-small.png") - span ClassLink + a#clever-signup-btn.network-login.sso-row(data-sso-used="clever") + img.sso-logo.sso-logo-clever(src="/images/pages/modal/auth/clever-logo-blue.png", draggable="false") + a#schoology-signup-btn.network-login.sso-row(data-sso-used="schoology") + img.sso-logo.sso-logo-schoology(src="/images/pages/modal/auth/schoology-logo-wordmark.svg") + a#classlink-signup-btn.network-login.sso-row(data-sso-used="classlink") + img.sso-logo.sso-logo-classlink(src="/images/pages/modal/auth/classlink-logo-text.png") + + // Mobile two-step: reveal the email/password form. A de-emphasized link + // (not a button) so the SSO integrations stay the primary path. + a#reveal-email-form.reveal-email-row(role='button' tabindex='0') + span(data-i18n="signup.signup_with_email") // TODO: turn on once we can ask about school inside ConfirmationView @@ -73,6 +74,9 @@ mixin use-social-signon form#basic-info-form.modal-body.basic-info - var path = view.signupState.get('path') .modal-body-content + // Mobile two-step: return from the email form back to the SSO options. + a#back-to-sso.back-to-sso-row(role='button' tabindex='0') + span(data-i18n="signup.other_signin_options") if path === 'individual' .individual-flow-panel .individual-flow-progress(aria-hidden="true") diff --git a/app/views/core/CreateAccountModal/BasicInfoView.js b/app/views/core/CreateAccountModal/BasicInfoView.js index 24c6ee2d2b8..1109e6e1997 100644 --- a/app/views/core/CreateAccountModal/BasicInfoView.js +++ b/app/views/core/CreateAccountModal/BasicInfoView.js @@ -60,6 +60,11 @@ module.exports = (BasicInfoView = (function () { 'click #clever-signup-btn': 'onClickSsoSignupButton', 'click #schoology-signup-btn': 'onClickSsoSignupButton', 'click #classlink-signup-btn': 'onClickSsoSignupButton', + 'click #google-signup-btn': 'onClickSsoSignupButton', + 'click #reveal-email-form': 'onClickRevealEmailForm', + 'keydown #reveal-email-form': 'onKeydownRevealEmailForm', + 'click #back-to-sso': 'onClickBackToSso', + 'keydown #back-to-sso': 'onKeydownBackToSso', } } @@ -116,17 +121,49 @@ module.exports = (BasicInfoView = (function () { } afterRender () { + // Mobile two-step signup: when SSO options exist, the email form starts + // collapsed behind a "Sign up with email" action (see basic-info-view.sass, + // gated on the `has-sso` class so library/no-SSO paths are unaffected). + if (this.$el.find('.auth-network-logins').length) { + this.$el.addClass('has-sso') + } this.$el.find('#first-name-input').focus() if (!me.showChinaRegistration()) { - application.gplusHandler.loadAPI({ - success: () => { - return this.handleSSOConnect(application.gplusHandler, 'gplus') - } - }) + // Load the Google Identity script so the custom Google row can trigger + // sign-in on click (handleSSOConnect -> gplusHandler.connect -> prompt). + // We intentionally do NOT auto-render the GSI button or auto-prompt + // One Tap here; the row is styled like the other providers. + application.gplusHandler.loadAPI() } return super.afterRender() } + // Mobile two-step: reveal the email/password form, hide the SSO chooser. + onClickRevealEmailForm (e) { + if (e) { e.preventDefault() } + this.$el.addClass('email-revealed') + const $firstField = this.$el.find('#first-name-input, #email-input').filter(':visible').first() + $firstField.focus() + } + + onKeydownRevealEmailForm (e) { + if (e.key === 'Enter' || e.key === ' ' || e.keyCode === 13 || e.keyCode === 32) { + this.onClickRevealEmailForm(e) + } + } + + // Mobile two-step: go back from the email form to the SSO chooser. + onClickBackToSso (e) { + if (e) { e.preventDefault() } + this.$el.removeClass('email-revealed') + } + + onKeydownBackToSso (e) { + if (e.key === 'Enter' || e.key === ' ' || e.keyCode === 13 || e.keyCode === 32) { + this.onClickBackToSso(e) + } + } + // These values are passed along to AuthModal if the user clicks "Sign In" (handled by CreateAccountModal) updateAuthModalInitialValues (values) { return this.signupState.set({ From 7b2b7acc9aa723b2e7f1ef19647c25e70671fd26 Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Tue, 23 Jun 2026 17:06:12 +0200 Subject: [PATCH 06/27] feat(auth): add unified auth route shell Add shared /signup and /login page foundation with route-level mode state and chooser cards. Keep downstream auth paths unvalidated and handed off for now. --- app/core/Router.js | 3 + app/styles/account/auth-view.sass | 173 ++++++++++++ app/templates/account/auth-view.pug | 69 +++++ app/views/account/AuthView.js | 358 ++++++++++++++++++++++++ test/app/views/account/AuthView.spec.js | 67 +++++ 5 files changed, 670 insertions(+) create mode 100644 app/styles/account/auth-view.sass create mode 100644 app/templates/account/auth-view.pug create mode 100644 app/views/account/AuthView.js create mode 100644 test/app/views/account/AuthView.spec.js diff --git a/app/core/Router.js b/app/core/Router.js index 27c575b2e1b..07ac58b917c 100644 --- a/app/core/Router.js +++ b/app/core/Router.js @@ -350,6 +350,9 @@ module.exports = (CocoRouter = (function () { }) }, + signup: go('account/AuthView'), + login: go('account/AuthView'), + parents: go('core/SingletonAppVueComponentView'), 'parents/*path': go('core/SingletonAppVueComponentView'), 'live-classes': go('core/SingletonAppVueComponentView'), diff --git a/app/styles/account/auth-view.sass b/app/styles/account/auth-view.sass new file mode 100644 index 00000000000..3daba9526f9 --- /dev/null +++ b/app/styles/account/auth-view.sass @@ -0,0 +1,173 @@ +@import "app/styles/style-flat-variables" +@import "app/styles/bootstrap/variables" + +#auth-view + min-height: 100vh + background: #f7f8fc + + .auth-view + padding: 48px 20px 72px + + .auth-view__content + max-width: 1120px + margin: 0 auto + display: grid + gap: 32px + + .auth-view__hero + display: grid + gap: 12px + text-align: center + + .auth-view__title + margin: 0 + color: $navy + font-size: 42px + font-weight: 700 + line-height: 1.08 + + .auth-view__subtitle + max-width: 720px + margin: 0 auto + color: $gray + font-size: 18px + line-height: 1.5 + + .auth-view__panel + background: white + border-radius: 16px + box-shadow: 0 18px 38px rgba(28, 42, 74, 0.12) + padding: 28px + display: grid + gap: 24px + + .auth-view__mode-switch + display: flex + gap: 8px + justify-content: flex-end + align-items: center + color: $gray + + a + color: $teal + font-weight: 600 + + .auth-chooser__grid + display: grid + grid-template-columns: minmax(0, 1fr) + gap: 20px + + .auth-path-button + appearance: none + border: 0 + background: transparent + padding: 0 + display: flex + flex-direction: column + gap: 12px + text-align: left + + .auth-path-image + width: 100% + aspect-ratio: 4 / 3 + object-fit: cover + border-radius: 24px + box-shadow: 0 16px 32px rgba(28, 42, 74, 0.14) + + .auth-path-cta + min-height: 56px + display: flex + align-items: center + justify-content: center + padding: 14px 18px + border-radius: 14px + background: linear-gradient(135deg, #8a6cff 0%, #6f5dff 100%) + color: white + font-size: 17px + font-weight: 700 + line-height: 1.2 + text-align: center + + .auth-path-description + color: $gray + font-size: 15px + line-height: 1.45 + text-align: center + padding: 0 8px + + .auth-login + max-width: 520px + width: 100% + margin: 0 auto + display: grid + gap: 24px + + .auth-login__password-row + display: flex + justify-content: space-between + gap: 12px + align-items: baseline + + .input-border + border: 1px solid #d9dfeb + border-radius: 12px + padding: 2px + + .form-control + border: 0 + box-shadow: none + height: 48px + + .auth-network-logins + display: grid + gap: 12px + grid-template-columns: repeat(2, minmax(0, 1fr)) + + .network-login + min-height: 52px + border: 1px solid #d9dfeb + border-radius: 12px + display: flex + align-items: center + justify-content: center + gap: 10px + padding: 12px + background: white + color: $navy + + img + max-height: 24px + max-width: 120px + + @media (min-width: 768px) + .auth-view__panel + padding: 36px + + .auth-chooser__grid + grid-template-columns: repeat(2, minmax(0, 1fr)) + gap: 28px 32px + + @media (max-width: 767px) + .auth-view + padding-top: 32px + + .auth-view__title + font-size: 32px + + .auth-view__subtitle + font-size: 16px + + .auth-view__panel + padding: 20px + + .auth-view__mode-switch + justify-content: flex-start + flex-wrap: wrap + + .auth-network-logins + grid-template-columns: minmax(0, 1fr) + + .auth-login__password-row + flex-direction: column + align-items: flex-start + gap: 6px diff --git a/app/templates/account/auth-view.pug b/app/templates/account/auth-view.pug new file mode 100644 index 00000000000..f5a6e0a7fac --- /dev/null +++ b/app/templates/account/auth-view.pug @@ -0,0 +1,69 @@ +mixin account-card(path, titleKey, descriptionKey, imageAltKey, imageSrc) + button.auth-path-button(type="button" data-path=path class=path + "-path-button") + img.auth-path-image(src=imageSrc data-i18n=imageAltKey) + span.auth-path-cta(data-i18n=titleKey) + span.auth-path-description(data-i18n=descriptionKey) + +.auth-view + .auth-view__content + .auth-view__hero + h1.auth-view__title(data-i18n=mode === 'login' ? 'login.log_in' : 'nav.create_free_account') + if mode === 'signup' + p.auth-view__subtitle Start with account type. Sign in stays one tap away. + else + p.auth-view__subtitle Welcome back. Use account details or existing sign-in provider. + + .auth-view__panel + .auth-view__mode-switch + if mode === 'signup' + span(data-i18n="login.signup_switch") + a.auth-mode-link(href="/login" data-mode="login" data-i18n="login.log_in") + else + span(data-i18n="login.signup_switch") + a.auth-mode-link(href="/signup" data-mode="signup" data-i18n="login.sign_up") + + if mode === 'signup' + .auth-chooser + .auth-chooser__grid + +account-card('teacher', 'new_home.im_an_educator', 'new_home.im_an_educator_description', '[alt]new_home.for_educators', '/images/pages/account/create/educator.png') + +account-card('parent', 'new_home.im_a_parent', 'new_home.im_a_parent_description', '[alt]new_home.for_parents', '/images/pages/account/create/parent.png') + +account-card('student', 'new_home.im_a_student', 'new_home.im_a_student_description', '[alt]new_home.for_students', '/images/pages/account/create/student.png') + +account-card('individual', 'new_home.im_an_individual', 'new_home.im_an_individual_description', '[alt]new_home.for_individuals', '/images/pages/account/create/individual.png') + else + .auth-login + #unknown-error-alert.alert.alert-danger.hide(data-i18n="loading_error.unknown") + form.auth-login-form.form + .form-group + label.control-label(for="username-or-email-input") + span(data-i18n="login.email_or_username") + | : + .input-border + input#username-or-email-input.input-large.form-control(name="emailOrUsername" value=previousFormInputs.email dir="auto") + + .form-group + .auth-login__password-row + label.control-label(for="password-input") + span(data-i18n="general.password") + | : + a#link-to-recover(href="#" data-i18n="login.forgot_password") + .input-border + input#password-input.input-large.form-control(name="password" type="password" value=previousFormInputs.password dir="auto") + + input#login-btn.btn.btn-lg.btn-success.btn-block(type="submit" value=translate('login.log_in')) + + if me.useSocialSignOn() + div(class="auth-network-logins" + (showClever ? " three-choices" : " two-choices")) + a#facebook-login-btn.network-login.signup-with-button(disabled=true) + img.fb-login-img(src="/images/pages/modal/auth/facebook_small.png", draggable="false") + span Facebook + if showClever + a#clever-signup-btn.network-login + img.clever-login-img(src="/images/pages/modal/auth/clever-logo-blue.png", draggable="false") + a#google-login-button.network-login(disabled=true) + img.google-login-img(src="/images/pages/modal/auth/gplus_sso_button2.svg" draggable="false") + a#schoology-login-btn.network-login.signup-with-button + img.schoology-login-img(src="/images/pages/modal/auth/schoology.png") + span Schoology + a#classlink-login-btn.network-login.signup-with-button + img.classlink-login-img(src="/images/pages/modal/auth/classlink-logo-small.png") + span ClassLink diff --git a/app/views/account/AuthView.js b/app/views/account/AuthView.js new file mode 100644 index 00000000000..d3a7499a261 --- /dev/null +++ b/app/views/account/AuthView.js @@ -0,0 +1,358 @@ +require('app/styles/account/auth-view.sass') +const RootView = require('views/core/RootView') +const utils = require('core/utils') +const forms = require('core/forms') +const errors = require('core/errors') +const RecoverModal = require('views/core/RecoverModal') +const User = require('models/User') +const { logInWithClever } = require('core/social-handlers/CleverHandler') +const SchoologyHandler = require('core/social-handlers/SchoologyHandler') +const ClassLinkHandler = require('core/social-handlers/ClassLinkHandler') +const template = require('app/templates/account/auth-view') + +class AuthView extends RootView { + static initClass () { + this.prototype.id = 'auth-view' + this.prototype.template = template + + this.prototype.events = { + 'click .auth-mode-link': 'onClickModeLink', + 'click .auth-path-button': 'onClickPathButton', + 'submit form.auth-login-form': 'onSubmitForm', + 'click #link-to-recover': 'openRecoverModal', + 'click #google-login-button': 'onClickGPlusLoginButton', + 'click #facebook-login-btn': 'onClickFacebookLoginButton', + 'click #clever-signup-btn': 'onClickCleverSignupButton', + 'click #clever-login-btn': 'onClickCleverLoginButton', + 'click #schoology-login-btn': 'onClickSchoologyLoginButton', + 'click #classlink-login-btn': 'onClickClasslinkLoginButton', + } + } + + initialize (options = {}) { + super.initialize(options) + this.utils = utils + this.onFacebookLoginError = this.onFacebookLoginError.bind(this) + } + + getRenderData () { + const context = super.getRenderData() + context.mode = this.getMode() + context.showClever = !/^\/play/.test(window.location.pathname) + context.previousFormInputs = { + email: '', + password: '', + } + return context + } + + getMode () { + return /\/login$/i.test(document.location.pathname) ? 'login' : 'signup' + } + + getTitle () { + return $.i18n.t(this.getMode() === 'login' ? 'login.log_in' : 'nav.create_free_account') + } + + afterRender () { + super.afterRender() + if (!me.useSocialSignOn()) { return } + + application.gplusHandler.loadAPI({ + success: () => { + if (!this.destroyed) { + this.$('#google-login-button').attr('disabled', false) + } + }, + }) + + if (utils.isCodeCombat) { + application.facebookHandler.loadAPI({ + success: () => { + if (!this.destroyed) { + this.$('#facebook-login-btn').attr('disabled', false) + } + }, + }) + } + } + + onClickModeLink (e) { + e.preventDefault() + const mode = $(e.currentTarget).data('mode') + application.router.navigate(`/${mode}`, { trigger: true }) + } + + onClickPathButton (e) { + e.preventDefault() + const path = $(e.currentTarget).data('path') + switch (path) { + case 'teacher': + window.tracker?.trackEvent('Teachers Create Account Loaded', { category: 'Teachers' }) + return application.router.navigate('/teachers/signup', { trigger: true }) + case 'parent': + window.location.href = '/parents/signup' + return + case 'student': + return application.router.navigate('/students', { trigger: true }) + case 'individual': + return application.router.navigate('/signup?type=individual', { trigger: true }) + } + } + + openRecoverModal (e) { + e.preventDefault() + return this.openModalView(new RecoverModal()) + } + + onSubmitForm (e) { + this.playSound('menu-button-click') + e.preventDefault() + forms.clearFormAlerts(this.$el) + this.$('#unknown-error-alert').addClass('hide') + const userObject = forms.formToObject(this.$el) + const res = tv4.validateMultiple(userObject, formSchema) + if (!res.valid) { return forms.applyErrorsToForm(this.$el, res.errors) } + let showingError = false + return new Promise(me.loginPasswordUser(userObject.emailOrUsername, userObject.password).then) + .catch(jqxhr => { + if (jqxhr.status === 401) { + const { errorID } = jqxhr.responseJSON + if (errorID === 'not-found') { + forms.setErrorToProperty(this.$el, 'emailOrUsername', $.i18n.t('loading_error.user_not_found')) + showingError = true + } + if (errorID === 'wrong-password') { + forms.setErrorToProperty(this.$el, 'password', $.i18n.t('account_settings.wrong_password')) + showingError = true + } + if (errorID === 'temp-password-expired') { + forms.setErrorToProperty(this.$el, 'password', $.i18n.t('account_settings.temp_password_expired')) + showingError = true + } + } else if (jqxhr.status === 429) { + showingError = true + forms.setErrorToProperty(this.$el, 'emailOrUsername', $.i18n.t('loading_error.too_many_login_failures')) + } + + if (!showingError) { + this.$('#unknown-error-alert').removeClass('hide') + } + }) + .then(() => { + application.tracker.identifyAfterNextPageLoad() + return application.tracker.identify() + }) + .finally(() => { + if (!showingError) { + loginNavigate() + } + }) + } + + onClickGPlusLoginButton (e) { + e.preventDefault() + const btn = this.$('#google-login-button') + return application.gplusHandler.connect({ + context: this, + success (resp = {}) { + btn.attr('disabled', true) + return application.gplusHandler.loadPerson({ + resp, + context: this, + success (gplusAttrs) { + const existingUser = new User() + return existingUser.fetchGPlusUser(gplusAttrs.gplusID, gplusAttrs.email, { + success: () => { + return me.loginGPlusUser(gplusAttrs.gplusID, { + success: () => { + application.tracker.identifyAfterNextPageLoad() + return application.tracker.identify().finally(() => loginNavigate()) + }, + error: this.onGPlusLoginError, + }) + }, + error: (res, jqxhr) => { + if ((jqxhr.status === 409) && jqxhr.responseJSON.errorID === 'account-with-email-exists') { + const mergeLogin = attrs => me.loginGPlusUser(attrs.gplusID, { + data: { merge: true, email: attrs.email }, + success: () => { + application.tracker.identifyAfterNextPageLoad() + return application.tracker.identify().finally(() => loginNavigate()) + }, + error: this.onGPlusLoginError, + }) + if (gplusAttrs.email?.includes(User.getNapervilleDomain())) { + return mergeLogin(gplusAttrs) + } + return noty({ + text: $.i18n.t('login.accounts_merge_confirmation'), + layout: 'topCenter', + type: 'info', + buttons: [ + { text: 'Yes', onClick ($noty) { $noty.close(); return mergeLogin(gplusAttrs) } }, + { text: 'No', onClick ($noty) { return $noty.close() } }, + ], + }) + } + return this.onGPlusLoginError(res, jqxhr) + }, + }) + }, + }) + }, + error: e2 => { + this.onGPlusLoginError() + if (e2?.error && e2?.details && !e2.message) { + e2.message = `Google login failed: ${e2.error} - ${e2.details}` + } + return noty({ text: e2?.message || e2?.details || e2?.toString?.() || 'Unknown Google login error', layout: 'topCenter', type: 'error', timeout: 5000, killer: false, dismissQueue: true }) + }, + }) + } + + onGPlusLoginError (res, jqxhr) { + if (((jqxhr != null ? jqxhr.status : undefined) === 401) && jqxhr.responseJSON.errorID === 'individuals-not-supported') { + forms.setErrorToProperty(this.$el, 'emailOrUsername', $.i18n.t('login.individual_users_not_supported')) + } else if (arguments.length) { + errors.showNotyNetworkError(...arguments) + } + + const btn = this.$('#google-login-button') + btn.attr('disabled', false) + } + + onClickFacebookLoginButton (e) { + e.preventDefault() + const btn = this.$('#facebook-login-btn') + return application.facebookHandler.connect({ + context: this, + success: response => { + btn.attr('disabled', true) + return application.facebookHandler.loadPerson({ + context: this, + success: facebookAttrs => { + const existingUser = new User() + return existingUser.fetchFacebookUser(facebookAttrs.facebookID, response?.authResponse?.accessToken, { + success: () => { + return me.loginFacebookUser(facebookAttrs.facebookID, response?.authResponse?.accessToken, { + success: () => { + application.tracker.identifyAfterNextPageLoad() + return application.tracker.identify().then(() => loginNavigate()) + }, + error: this.onFacebookLoginError, + }) + }, + error: this.onFacebookLoginError, + }) + }, + }) + }, + }) + } + + onFacebookLoginError (res) { + this.$('#unknown-error-alert').addClass('hide') + if (res.errorID === 'individuals-not-supported') { + forms.setErrorToProperty(this.$el, 'emailOrUsername', $.i18n.t('login.individual_users_not_supported')) + this.$('#unknown-error-alert').removeClass('hide') + } else if (res.code === 404) { + forms.setErrorToProperty(this.$el, 'emailOrUsername', $.i18n.t('loading_error.user_not_found')) + this.$('#unknown-error-alert').removeClass('hide') + } + + const btn = this.$('#facebook-login-btn') + btn.attr('disabled', false) + return errors.showNotyNetworkError(...arguments) + } + + onClickCleverSignupButton (e) { + e.preventDefault() + let cleverClientId, districtId, redirectTo + if (['next.codecombat.com', 'localhost'].includes(window.location.hostname)) { + cleverClientId = '943ece596555cac13fcc' + redirectTo = 'https://next.codecombat.com/auth/login-clever' + districtId = '5b2ad81a709e300001e2cd7a' + } else { + cleverClientId = 'ffce544a7e02c0daabf2' + redirectTo = 'https://codecombat.com/auth/login-clever' + } + let url = `https://clever.com/oauth/authorize?response_type=code&redirect_uri=${encodeURIComponent(redirectTo)}&client_id=${cleverClientId}` + if (districtId) { + url += '&district_id=' + districtId + } + return window.open(url, '_blank') + } + + onClickCleverLoginButton (e) { + e.preventDefault() + return logInWithClever() + } + + async onClickSchoologyLoginButton (e) { + e.preventDefault() + const handler = new SchoologyHandler() + return this.onClickEdlinkLoginButton(handler) + } + + async onClickClasslinkLoginButton (e) { + e.preventDefault() + const handler = new ClassLinkHandler() + return this.onClickEdlinkLoginButton(handler) + } + + async onClickEdlinkLoginButton (handler) { + const { loggedIn } = await handler.logInWithEdlink() + if (loggedIn) { + window.location.reload() + } else { + noty({ text: $.i18n.t('login.login_failed'), layout: 'topCenter', type: 'error', timeout: 5000, killer: false, dismissQueue: true }) + } + } +} + +AuthView.initClass() +module.exports = AuthView + +const formSchema = { + type: 'object', + properties: { + emailOrUsername: { + $or: [ + User.schema.properties.name, + User.schema.properties.email, + ], + }, + }, + required: ['emailOrUsername', 'password'], +} + +function loginNavigate () { + if (window.nextURL) { + window.location.href = window.nextURL + return + } + + if (!me.isAdmin()) { + if (me.isAPIClient()) { + application.router.navigate('/partner-dashboard', { trigger: true }) + } else if (me.isStudent()) { + application.router.navigate('/students', { trigger: true }) + } else if (me.isTeacher()) { + if (me.isSchoolAdmin()) { + if (utils.isCodeCombat) { + application.router.navigate('/teachers/licenses', { trigger: true }) + } else { + application.router.navigate('/school-administrator', { trigger: true }) + } + } else { + application.router.navigate('/teachers/classes', { trigger: true }) + } + } else if (me.isParentHome()) { + const routeStr = me.hasNoVerifiedChild() ? '/parents/add-another-child' : '/parents/dashboard' + application.router.navigate(routeStr, { trigger: true }) + } + } + + window.location.reload() +} diff --git a/test/app/views/account/AuthView.spec.js b/test/app/views/account/AuthView.spec.js new file mode 100644 index 00000000000..7698a54320f --- /dev/null +++ b/test/app/views/account/AuthView.spec.js @@ -0,0 +1,67 @@ +const AuthView = require('views/account/AuthView') + +describe('AuthView', function () { + let view + + beforeEach(function () { + jasmine.clock().install() + }) + + afterEach(function () { + jasmine.clock().uninstall() + if (view) { + view.destroy() + view = null + } + window.history.replaceState({}, '', '/') + }) + + function renderAt (path) { + window.history.replaceState({}, '', path) + view = new AuthView() + view.render() + jasmine.demoEl(view.$el) + return view + } + + it('renders signup chooser at /signup', function () { + renderAt('/signup') + + expect(view.getMode()).toBe('signup') + expect(view.$('.auth-path-button').length).toBe(4) + expect(view.$('.auth-login-form').length).toBe(0) + }) + + it('renders login form at /login', function () { + renderAt('/login') + + expect(view.getMode()).toBe('login') + expect(view.$('.auth-login-form').length).toBe(1) + expect(view.$('.auth-path-button').length).toBe(0) + }) + + it('toggles from signup to login in-page', function () { + renderAt('/signup') + jasmine.spyOn(application.router, 'navigate') + + view.$('.auth-mode-link').click() + + expect(application.router.navigate).toHaveBeenCalledWith('/login', { trigger: true }) + }) + + it('routes chooser cards to current destinations', function () { + renderAt('/signup') + jasmine.spyOn(application.router, 'navigate') + + view.$('.teacher-path-button').click() + expect(application.router.navigate).toHaveBeenCalledWith('/teachers/signup', { trigger: true }) + + application.router.navigate.calls.reset() + view.$('.student-path-button').click() + expect(application.router.navigate).toHaveBeenCalledWith('/students', { trigger: true }) + + application.router.navigate.calls.reset() + view.$('.individual-path-button').click() + expect(application.router.navigate).toHaveBeenCalledWith('/signup?type=individual', { trigger: true }) + }) +}) From 1b6c3c3f7c9533cd64c527f9e942afa7004c9c97 Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Tue, 23 Jun 2026 18:12:22 +0200 Subject: [PATCH 07/27] fix(auth): register unified auth view for lazy loading --- app/lib/dynamicRequire.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/lib/dynamicRequire.js b/app/lib/dynamicRequire.js index f0df3d164eb..c91cf409bd9 100644 --- a/app/lib/dynamicRequire.js +++ b/app/lib/dynamicRequire.js @@ -18,6 +18,7 @@ module.exports = { 'views/account/SubscriptionView': function () { return import(/* webpackChunkName: "account" */ 'views/account/SubscriptionView') }, 'views/account/InvoicesView': function () { return import(/* webpackChunkName: "account" */ 'views/account/InvoicesView') }, 'views/account/PrepaidView': function () { return import(/* webpackChunkName: "account" */ 'views/account/PrepaidView') }, + 'views/account/AuthView': function () { return import(/* webpackChunkName: "account" */ 'views/account/AuthView') }, 'views/admin/MainAdminView': function () { return import(/* webpackChunkName: "admin" */ 'views/admin/MainAdminView') }, 'views/admin/CLAsView': function () { return import(/* webpackChunkName: "admin" */ 'views/admin/CLAsView') }, 'views/admin/AdminClassroomContentView': function () { return import(/* webpackChunkName: "admin" */ 'views/admin/AdminClassroomContentView') }, From b7e9c35087dda59d549081d862a31c532f9e4a53 Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Tue, 23 Jun 2026 19:01:30 +0200 Subject: [PATCH 08/27] fix(auth): make individual signup path render safely --- app/styles/account/auth-view.sass | 202 +++++++++++- app/templates/account/auth-view.pug | 209 ++++++++++-- app/views/account/AuthView.js | 485 +++++++++++++++++++++++++++- 3 files changed, 857 insertions(+), 39 deletions(-) diff --git a/app/styles/account/auth-view.sass b/app/styles/account/auth-view.sass index 3daba9526f9..fe825d60153 100644 --- a/app/styles/account/auth-view.sass +++ b/app/styles/account/auth-view.sass @@ -3,26 +3,82 @@ #auth-view min-height: 100vh - background: #f7f8fc + background: #f6f7ff .auth-view - padding: 48px 20px 72px + padding: 32px 20px 72px .auth-view__content max-width: 1120px margin: 0 auto display: grid - gap: 32px + gap: 28px .auth-view__hero display: grid gap: 12px text-align: center + .auth-view__hero--chooser + max-width: 760px + margin: 0 auto + + .auth-view__hero-art + position: relative + min-height: 180px + border-radius: 22px + background: linear-gradient(145deg, #7a65fc 0%, #9487ff 56%, #b9aeff 100%) + overflow: hidden + box-shadow: 0 18px 38px rgba(28, 42, 74, 0.14) + + &:before, + &:after + content: "" + position: absolute + background: rgba(255, 255, 255, 0.16) + border-radius: 6px + + &:before + width: 24px + height: 24px + top: 18% + left: 16% + transform: rotate(16deg) + + &:after + width: 18px + height: 18px + right: 14% + top: 22% + transform: rotate(-18deg) + + .auth-view__hero-code + position: absolute + left: 18px + right: 18px + bottom: 16px + border-radius: 12px + background: rgba(13, 10, 40, 0.58) + padding: 12px 14px + text-align: left + color: white + font-family: ui-monospace, SFMono-Regular, Menlo, monospace + font-size: 13px + line-height: 1.55 + + .auth-view__hero-code-label + color: #b9b2ff + + .auth-view__hero-code-action + color: white + + .auth-view__hero-code-paren + color: #ffd479 + .auth-view__title margin: 0 color: $navy - font-size: 42px + font-size: 40px font-weight: 700 line-height: 1.08 @@ -30,17 +86,21 @@ max-width: 720px margin: 0 auto color: $gray - font-size: 18px + font-size: 17px line-height: 1.5 .auth-view__panel background: white - border-radius: 16px + border-radius: 20px box-shadow: 0 18px 38px rgba(28, 42, 74, 0.12) padding: 28px display: grid gap: 24px + .auth-view__panel--individual + max-width: 680px + margin: 0 auto + .auth-view__mode-switch display: flex gap: 8px @@ -102,6 +162,101 @@ display: grid gap: 24px + .auth-individual + display: grid + gap: 20px + + .auth-individual__progress + display: grid + grid-template-columns: repeat(2, minmax(0, 1fr)) + gap: 10px + + .auth-individual__progress-step + height: 8px + border-radius: 999px + background: rgba(28, 54, 87, 0.12) + + .auth-individual__progress-step--active + background: linear-gradient(90deg, #f97316 0%, #ea580c 100%) + + .auth-birthday-grid + display: grid + grid-template-columns: minmax(0, 1.35fr) minmax(0, 0.85fr) minmax(0, 1fr) + gap: 12px + + .auth-birthday-help + margin: 0 + color: #4b637c + font-size: 14px + line-height: 1.5 + + .auth-individual__social + display: grid + gap: 14px + padding: 28px 24px + border-radius: 24px + background: linear-gradient(180deg, #fffef8 0%, #fff5d1 100%) + box-shadow: 0 20px 50px rgba(15, 23, 42, 0.12) + + .auth-individual__social--collapsed + display: none + + .auth-individual__social-title + margin: 0 + color: #17314d + font-size: 28px + line-height: 1.15 + font-weight: 700 + + .auth-individual-form + display: none + gap: 16px + padding: 28px 24px + border-radius: 24px + background: linear-gradient(180deg, #fffef8 0%, #fff5d1 100%) + box-shadow: 0 20px 50px rgba(15, 23, 42, 0.12) + + .auth-individual-form--visible + display: grid + + .auth-parent-email + .alert + margin-bottom: 0 + + .auth-parent-email__blurb + border-radius: 14px + background: #e9e8fd + padding: 14px 16px + color: #17314d + font-size: 13.5px + line-height: 1.5 + + .auth-individual__actions + display: flex + gap: 12px + align-items: center + + .btn-primary, + #create-individual-account-btn, + .auth-continue-btn + flex: 1 + min-height: 54px + border: 0 + border-radius: 14px + background: linear-gradient(135deg, #7a65fc 0%, #6a52f0 100%) + color: white + font-size: 16px + font-weight: 700 + + .auth-reveal-email, + .auth-hide-email, + .auth-parent-email-back + border: 0 + background: transparent + color: $teal + font-weight: 600 + padding: 0 + .auth-login__password-row display: flex justify-content: space-between @@ -112,17 +267,26 @@ border: 1px solid #d9dfeb border-radius: 12px padding: 2px + background: white .form-control border: 0 box-shadow: none height: 48px + .auth-field-status + min-height: 22px + padding-top: 8px + line-height: 18px + .auth-network-logins display: grid gap: 12px grid-template-columns: repeat(2, minmax(0, 1fr)) + .auth-network-logins--stacked + grid-template-columns: minmax(0, 1fr) + .network-login min-height: 52px border: 1px solid #d9dfeb @@ -139,6 +303,9 @@ max-height: 24px max-width: 120px + .signup-with-button + justify-content: flex-start + @media (min-width: 768px) .auth-view__panel padding: 36px @@ -149,7 +316,7 @@ @media (max-width: 767px) .auth-view - padding-top: 32px + padding-top: 24px .auth-view__title font-size: 32px @@ -171,3 +338,24 @@ flex-direction: column align-items: flex-start gap: 6px + + .auth-birthday-grid + grid-template-columns: 1fr + + .auth-individual__social, + .auth-individual-form + padding: 24px 18px + border-radius: 20px + + .auth-individual__social-title + font-size: 24px + + .auth-individual__actions + flex-direction: column-reverse + + .auth-hide-email, + .auth-parent-email-back, + .btn-primary, + #create-individual-account-btn, + .auth-continue-btn + width: 100% diff --git a/app/templates/account/auth-view.pug b/app/templates/account/auth-view.pug index f5a6e0a7fac..29f3ab815df 100644 --- a/app/templates/account/auth-view.pug +++ b/app/templates/account/auth-view.pug @@ -4,66 +4,217 @@ mixin account-card(path, titleKey, descriptionKey, imageAltKey, imageSrc) span.auth-path-cta(data-i18n=titleKey) span.auth-path-description(data-i18n=descriptionKey) +mixin social-signup-button(id, label, imageSrc, imageClass) + a.network-login.signup-with-button(id=id href="#" disabled=true) + img(class=imageClass src=imageSrc draggable="false") + span= label + .auth-view .auth-view__content - .auth-view__hero - h1.auth-view__title(data-i18n=mode === 'login' ? 'login.log_in' : 'nav.create_free_account') - if mode === 'signup' - p.auth-view__subtitle Start with account type. Sign in stays one tap away. - else + .auth-view__hero(class=signupStep === 'chooser' ? 'auth-view__hero--chooser' : '') + if mode === 'signup' && signupStep === 'chooser' + .auth-view__hero-art + .auth-view__hero-code + span.auth-view__hero-code-label hero. + span.auth-view__hero-code-action moveRight + span.auth-view__hero-code-paren () + br + span.auth-view__hero-code-label hero. + span.auth-view__hero-code-action attack + span.auth-view__hero-code-paren ('ogre') + h1.auth-view__title Learn to code by playing. + p.auth-view__subtitle Start with account type, then move through one clear path. + else if mode === 'signup' && signupPath === 'individual' && signupStep === 'birthday' + h1.auth-view__title When’s your birthday? + p.auth-view__subtitle Parents, please enter your own birthday. We’ll set the right experience. + else if mode === 'signup' && signupPath === 'individual' && signupStep === 'individual' + h1.auth-view__title Create your account + p.auth-view__subtitle You’re one step away from unlocking the fun of coding. + else if mode === 'signup' && signupPath === 'individual' && signupStep === 'parent-email' + h1.auth-view__title What’s your parent’s email? + p.auth-view__subtitle We’ll send instructions so they can finish creating your account. + else if mode === 'login' + h1.auth-view__title(data-i18n='login.log_in') p.auth-view__subtitle Welcome back. Use account details or existing sign-in provider. + else + h1.auth-view__title(data-i18n='nav.create_free_account') + p.auth-view__subtitle Start with account type. Sign in stays one tap away. - .auth-view__panel + .auth-view__panel(class=signupPath === 'individual' ? 'auth-view__panel--individual' : '') .auth-view__mode-switch if mode === 'signup' - span(data-i18n="login.signup_switch") - a.auth-mode-link(href="/login" data-mode="login" data-i18n="login.log_in") + span(data-i18n='login.signup_switch') + a.auth-mode-link(href='/login' data-mode='login' data-i18n='login.log_in') else - span(data-i18n="login.signup_switch") - a.auth-mode-link(href="/signup" data-mode="signup" data-i18n="login.sign_up") + span(data-i18n='login.signup_switch') + a.auth-mode-link(href='/signup' data-mode='signup' data-i18n='login.sign_up') - if mode === 'signup' + if mode === 'signup' && signupStep === 'chooser' .auth-chooser .auth-chooser__grid +account-card('teacher', 'new_home.im_an_educator', 'new_home.im_an_educator_description', '[alt]new_home.for_educators', '/images/pages/account/create/educator.png') +account-card('parent', 'new_home.im_a_parent', 'new_home.im_a_parent_description', '[alt]new_home.for_parents', '/images/pages/account/create/parent.png') +account-card('student', 'new_home.im_a_student', 'new_home.im_a_student_description', '[alt]new_home.for_students', '/images/pages/account/create/student.png') +account-card('individual', 'new_home.im_an_individual', 'new_home.im_an_individual_description', '[alt]new_home.for_individuals', '/images/pages/account/create/individual.png') + else if mode === 'signup' && signupPath === 'individual' && signupStep === 'birthday' + .auth-individual + .auth-individual__progress + span.auth-individual__progress-step.auth-individual__progress-step--active + span.auth-individual__progress-step + form.auth-birthday-form + .auth-birthday-grid + .form-group + label.control-label(for='birthday-month-input') Month + .input-border + select#birthday-month-input.form-control(name='birthdayMonth') + each monthKey, index in months + - var month = index + 1 + option(value=month selected=(month === signupBirthdayMonth) data-i18n=monthKey) + .form-group + label.control-label(for='birthday-day-input') Day + .input-border + select#birthday-day-input.form-control(name='birthdayDay') + option(value='') Day + each day in _.range(1, 32) + option(value=day selected=(day === signupBirthdayDay)) #{day} + .form-group + label.control-label(for='birthday-year-input') Year + .input-border + select#birthday-year-input.form-control(name='birthdayYear') + option(value='') Year + each year in _.range(currentYear, currentYear - 100, -1) + option(value=year selected=(year === signupBirthdayYear)) #{year} + p.auth-birthday-help Parents, please enter your own birthday — we’ll set the right experience. + .auth-individual__actions + button.btn.btn-lg.btn-primary.auth-continue-btn(type='submit') Continue + else if mode === 'signup' && signupPath === 'individual' && signupStep === 'individual' + .auth-individual + .auth-individual__progress + span.auth-individual__progress-step + span.auth-individual__progress-step.auth-individual__progress-step--active + .auth-individual__social(class=authState.showEmailForm ? 'auth-individual__social--collapsed' : '') + h2.auth-individual__social-title Continue with one click + .auth-network-logins.auth-network-logins--stacked + +social-signup-button('google-signup-button', 'Continue with Google', '/images/pages/modal/auth/gplus_sso_button2.svg', 'google-login-img') + +social-signup-button('facebook-signup-btn', 'Continue with Facebook', '/images/pages/modal/auth/facebook_small.png', 'fb-login-img') + button.auth-reveal-email(type='button') Or sign up with email + form.auth-individual-form(class=authState.showEmailForm ? 'auth-individual-form--visible' : '') + .form-group + if !hideEmail + label.control-label(for='individual-email-input') + span(data-i18n='share_progress_modal.form_label') + .input-border + input#individual-email-input.input-large.form-control(name='email' type='email' value=signupForm.email dir='auto') + .auth-field-status + if authState.checkEmailState === 'checking' + span.small(data-i18n='signup.checking') + if authState.checkEmailState === 'exists' + span.small + span.text-burgundy.glyphicon.glyphicon-remove-circle + | + span(data-i18n='signup.account_exists') + if authState.checkEmailState === 'available' + span.small + span.text-forest.glyphicon.glyphicon-ok-circle + | + span(data-i18n='signup.email_good') + if showLibraryIdInsteadOfUsername + .form-group + .row.library-signup-text + span(data-i18n='library.library_signup_promotion') + .form-group + if showLibraryIdInsteadOfUsername + label.control-label(for='individual-username-input') + span(data-i18n='library.library_card') + else + label.control-label(for='individual-username-input') + span(data-i18n='general.username') + .input-border + input#individual-username-input.input-large.form-control(name='name' value=signupForm.name dir='auto' spellcheck='false') + .auth-field-status + if authState.checkNameState === 'checking' + span.small(data-i18n='signup.checking') + if authState.checkNameState === 'exists' + span.small= authState.suggestedNameText + if authState.checkNameState === 'available' + span.small + span.text-forest.glyphicon.glyphicon-ok-circle + | + span(data-i18n='signup.name_available') + .form-group + label.control-label(for='individual-password-input') + span(data-i18n='general.password') + .input-border + input#individual-password-input.input-large.form-control(name='password' type='password' value=signupForm.password dir='auto') + small.form-text.text-muted(data-i18n='signup.password_requirements') + if authState.error + .alert.alert-danger= authState.error + .auth-individual__actions + button.auth-hide-email(type='button') Back + button#create-individual-account-btn.btn.btn-lg.btn-primary(type='submit' data-i18n='login.sign_up') + else if mode === 'signup' && signupPath === 'individual' && signupStep === 'parent-email' + .auth-individual.auth-parent-email + .auth-individual__progress + span.auth-individual__progress-step.auth-individual__progress-step--active + span.auth-individual__progress-step + form.auth-parent-email-form + if !parentEmailSent + .form-group + label.control-label(for='parent-email-input') Parent email + .input-border + input#parent-email-input.input-large.form-control(name='parentEmail' type='email' value=parentEmail dir='auto') + if parentEmailError + .alert.alert-danger + span(data-i18n='signup.parent_email_error') + if dontUseOurEmailSilly + .alert.alert-warning + span(data-i18n='signup.dont_use_our_email_silly') + if parentEmailSent + .alert.alert-success + span(data-i18n='signup.parent_email_sent') + else + .auth-parent-email__blurb + span We’re excited for you to start coding. Your parent will get an email with instructions to create your account. Questions? + a(href='mailto:team@codecombat.com') team@codecombat.com + .auth-individual__actions + button.auth-parent-email-back(type='button') Back + button.btn.btn-lg.btn-primary(type='submit' disabled=parentEmailSending || dontUseOurEmailSilly) + if parentEmailSent + span(data-i18n='common.sent') + else + span Send to my parent else .auth-login - #unknown-error-alert.alert.alert-danger.hide(data-i18n="loading_error.unknown") + #unknown-error-alert.alert.alert-danger.hide(data-i18n='loading_error.unknown') form.auth-login-form.form .form-group - label.control-label(for="username-or-email-input") - span(data-i18n="login.email_or_username") + label.control-label(for='username-or-email-input') + span(data-i18n='login.email_or_username') | : .input-border - input#username-or-email-input.input-large.form-control(name="emailOrUsername" value=previousFormInputs.email dir="auto") - + input#username-or-email-input.input-large.form-control(name='emailOrUsername' value=previousFormInputs.email dir='auto') .form-group .auth-login__password-row - label.control-label(for="password-input") - span(data-i18n="general.password") + label.control-label(for='password-input') + span(data-i18n='general.password') | : - a#link-to-recover(href="#" data-i18n="login.forgot_password") + a#link-to-recover(href='#' data-i18n='login.forgot_password') .input-border - input#password-input.input-large.form-control(name="password" type="password" value=previousFormInputs.password dir="auto") - - input#login-btn.btn.btn-lg.btn-success.btn-block(type="submit" value=translate('login.log_in')) - + input#password-input.input-large.form-control(name='password' type='password' value=previousFormInputs.password dir='auto') + input#login-btn.btn.btn-lg.btn-success.btn-block(type='submit' value=translate('login.log_in')) if me.useSocialSignOn() - div(class="auth-network-logins" + (showClever ? " three-choices" : " two-choices")) + div(class='auth-network-logins' + (showClever ? ' three-choices' : ' two-choices')) a#facebook-login-btn.network-login.signup-with-button(disabled=true) - img.fb-login-img(src="/images/pages/modal/auth/facebook_small.png", draggable="false") + img.fb-login-img(src='/images/pages/modal/auth/facebook_small.png', draggable='false') span Facebook if showClever a#clever-signup-btn.network-login - img.clever-login-img(src="/images/pages/modal/auth/clever-logo-blue.png", draggable="false") + img.clever-login-img(src='/images/pages/modal/auth/clever-logo-blue.png', draggable='false') a#google-login-button.network-login(disabled=true) - img.google-login-img(src="/images/pages/modal/auth/gplus_sso_button2.svg" draggable="false") + img.google-login-img(src='/images/pages/modal/auth/gplus_sso_button2.svg' draggable='false') a#schoology-login-btn.network-login.signup-with-button - img.schoology-login-img(src="/images/pages/modal/auth/schoology.png") + img.schoology-login-img(src='/images/pages/modal/auth/schoology.png') span Schoology a#classlink-login-btn.network-login.signup-with-button - img.classlink-login-img(src="/images/pages/modal/auth/classlink-logo-small.png") + img.classlink-login-img(src='/images/pages/modal/auth/classlink-logo-small.png') span ClassLink diff --git a/app/views/account/AuthView.js b/app/views/account/AuthView.js index d3a7499a261..cd2463f14b6 100644 --- a/app/views/account/AuthView.js +++ b/app/views/account/AuthView.js @@ -4,7 +4,13 @@ const utils = require('core/utils') const forms = require('core/forms') const errors = require('core/errors') const RecoverModal = require('views/core/RecoverModal') +const contact = require('core/contact') const User = require('models/User') +const State = require('models/State') +const store = require('core/store') +const globalVar = require('core/globalVar') +const userUtils = require('lib/user-utils') +const { isCodeCombat } = require('core/utils') const { logInWithClever } = require('core/social-handlers/CleverHandler') const SchoologyHandler = require('core/social-handlers/SchoologyHandler') const ClassLinkHandler = require('core/social-handlers/ClassLinkHandler') @@ -18,11 +24,29 @@ class AuthView extends RootView { this.prototype.events = { 'click .auth-mode-link': 'onClickModeLink', 'click .auth-path-button': 'onClickPathButton', + 'click .auth-back-link': 'onClickBackLink', + 'change input[name="birthdayMonth"]': 'onInputBirthday', + 'change input[name="birthdayDay"]': 'onInputBirthday', + 'change input[name="birthdayYear"]': 'onInputBirthday', + 'submit form.auth-birthday-form': 'onSubmitBirthdayForm', + 'click .auth-parent-email-back': 'onClickParentEmailBack', + 'change input[name="parentEmail"]': 'onChangeParentEmail', + 'submit form.auth-parent-email-form': 'onSubmitParentEmailForm', + 'click .auth-reveal-email': 'onClickRevealEmailForm', + 'click .auth-hide-email': 'onClickHideEmailForm', + 'change input[name="email"]': 'onChangeEmail', + 'change input[name="name"]': 'onChangeName', + 'change input[name="password"]': 'onChangePassword', + 'submit form.auth-individual-form': 'onSubmitIndividualForm', 'submit form.auth-login-form': 'onSubmitForm', 'click #link-to-recover': 'openRecoverModal', + 'click #google-signup-button': 'onClickGoogleSignupButton', + 'click #facebook-signup-btn': 'onClickFacebookSignupButton', + 'click #clever-signup-btn': 'onClickCleverSignupButton', + 'click #schoology-signup-btn': 'onClickSchoologySignupButton', + 'click #classlink-signup-btn': 'onClickClasslinkSignupButton', 'click #google-login-button': 'onClickGPlusLoginButton', 'click #facebook-login-btn': 'onClickFacebookLoginButton', - 'click #clever-signup-btn': 'onClickCleverSignupButton', 'click #clever-login-btn': 'onClickCleverLoginButton', 'click #schoology-login-btn': 'onClickSchoologyLoginButton', 'click #classlink-login-btn': 'onClickClasslinkLoginButton', @@ -33,6 +57,60 @@ class AuthView extends RootView { super.initialize(options) this.utils = utils this.onFacebookLoginError = this.onFacebookLoginError.bind(this) + this.signupState = new State({ + path: this.getInitialPath(), + step: this.getInitialSignupStep(), + birthday: new Date(''), + birthdayMonth: this.getInitialBirthdayMonth(), + birthdayDay: '', + birthdayYear: '', + parentEmail: '', + parentEmailSent: false, + parentEmailSending: false, + parentEmailError: false, + dontUseOurEmailSilly: false, + ssoUsed: null, + ssoAttrs: null, + ssoResp: null, + signupForm: { + email: '', + name: '', + password: '', + }, + authModalInitialValues: {}, + }) + this.state = new State({ + showEmailForm: false, + checkEmailState: 'standby', + checkEmailValue: null, + checkEmailPromise: null, + checkNameState: 'standby', + checkNameValue: null, + checkNamePromise: null, + suggestedName: '', + suggestedNameText: '...', + error: '', + loginError: '', + }) + this.hideEmail = isCodeCombat ? userUtils.shouldHideEmail() : false + this.showLibraryIdInsteadOfUsername = isCodeCombat ? userUtils.shouldShowLibraryLoginModal() : false + this.listenTo(this.signupState, 'change', () => this.render()) + this.listenTo(this.state, 'change', () => this.render()) + } + + getInitialPath () { + if (this.getMode() !== 'signup') { return null } + const type = new URLSearchParams(window.location.search).get('type') + return type === 'individual' ? 'individual' : null + } + + getInitialSignupStep () { + if (this.getMode() !== 'signup') { return null } + return this.getInitialPath() === 'individual' ? 'birthday' : 'chooser' + } + + getInitialBirthdayMonth () { + return new Date().getUTCMonth() + 1 } getRenderData () { @@ -43,6 +121,35 @@ class AuthView extends RootView { email: '', password: '', } + context.signupPath = this.signupState.get('path') + context.signupStep = this.signupState.get('step') + context.signupForm = this.signupState.get('signupForm') + context.signupBirthdayMonth = this.signupState.get('birthdayMonth') + context.signupBirthdayDay = this.signupState.get('birthdayDay') + context.signupBirthdayYear = this.signupState.get('birthdayYear') + context.parentEmail = this.signupState.get('parentEmail') + context.parentEmailSent = this.signupState.get('parentEmailSent') + context.parentEmailSending = this.signupState.get('parentEmailSending') + context.parentEmailError = this.signupState.get('parentEmailError') + context.dontUseOurEmailSilly = this.signupState.get('dontUseOurEmailSilly') + context.authState = this.state.attributes + context.hideEmail = this.hideEmail + context.showLibraryIdInsteadOfUsername = this.showLibraryIdInsteadOfUsername + context.months = [ + 'calendar.january', + 'calendar.february', + 'calendar.march', + 'calendar.april', + 'calendar.may', + 'calendar.june', + 'calendar.july', + 'calendar.august', + 'calendar.september', + 'calendar.october', + 'calendar.november', + 'calendar.december', + ] + context.currentYear = new Date().getFullYear() return context } @@ -51,7 +158,13 @@ class AuthView extends RootView { } getTitle () { - return $.i18n.t(this.getMode() === 'login' ? 'login.log_in' : 'nav.create_free_account') + if (this.getMode() === 'login') { + return $.i18n.t('login.log_in') + } + if (this.signupState.get('step') === 'birthday') { + return $.i18n.t('nav.create_free_account') + } + return $.i18n.t('nav.create_free_account') } afterRender () { @@ -62,6 +175,7 @@ class AuthView extends RootView { success: () => { if (!this.destroyed) { this.$('#google-login-button').attr('disabled', false) + this.$('#google-signup-button').attr('disabled', false) } }, }) @@ -71,6 +185,7 @@ class AuthView extends RootView { success: () => { if (!this.destroyed) { this.$('#facebook-login-btn').attr('disabled', false) + this.$('#facebook-signup-btn').attr('disabled', false) } }, }) @@ -83,6 +198,12 @@ class AuthView extends RootView { application.router.navigate(`/${mode}`, { trigger: true }) } + onClickBackLink (e) { + e.preventDefault() + const step = $(e.currentTarget).data('step') + this.signupState.set({ step }) + } + onClickPathButton (e) { e.preventDefault() const path = $(e.currentTarget).data('path') @@ -96,8 +217,362 @@ class AuthView extends RootView { case 'student': return application.router.navigate('/students', { trigger: true }) case 'individual': - return application.router.navigate('/signup?type=individual', { trigger: true }) + this.signupState.set({ path: 'individual', step: 'birthday' }) + this.state.set({ showEmailForm: false, error: '' }) + window.history.replaceState({}, '', '/signup?type=individual') + } + } + + onInputBirthday () { + const birthdayMonth = parseInt(this.$('[name="birthdayMonth"]').val(), 10) + const birthdayDay = parseInt(this.$('[name="birthdayDay"]').val(), 10) + const birthdayYear = parseInt(this.$('[name="birthdayYear"]').val(), 10) + const birthday = new Date(Date.UTC(birthdayYear, birthdayMonth - 1, birthdayDay)) + this.signupState.set({ birthdayMonth, birthdayDay, birthdayYear, birthday }, { silent: true }) + if (!_.isNaN(birthday.getTime())) { + forms.clearFormAlerts(this.$el) + } + } + + onSubmitBirthdayForm (e) { + e.preventDefault() + this.onInputBirthday() + const birthday = this.signupState.get('birthday') + if (_.isNaN(birthday.getTime())) { + forms.clearFormAlerts(this.$el) + forms.setErrorToProperty(this.$el, 'birthdayDay', _.string.titleize($.i18n.t('common.required_field'))) + return } + const age = (new Date().getTime() - birthday.getTime()) / 365.4 / 24 / 60 / 60 / 1000 + if (age > utils.ageOfConsent(me.get('country'), 13)) { + this.signupState.set({ step: 'individual', path: 'individual' }) + this.state.set({ showEmailForm: false, error: '' }) + } else { + this.signupState.set({ step: 'parent-email', path: 'individual' }) + this.state.set({ error: '' }) + } + } + + onClickRevealEmailForm (e) { + e.preventDefault() + this.state.set({ showEmailForm: true }) + } + + onClickHideEmailForm (e) { + e.preventDefault() + this.state.set({ showEmailForm: false }) + } + + onClickParentEmailBack (e) { + e.preventDefault() + this.signupState.set({ step: 'birthday' }) + } + + onChangeParentEmail (e) { + const parentEmail = $(e.currentTarget).val() + this.signupState.set({ parentEmail }, { silent: true }) + this.signupState.set({ + dontUseOurEmailSilly: /team@codecombat.com/i.test(parentEmail), + parentEmailError: false, + }) + } + + onSubmitParentEmailForm (e) { + e.preventDefault() + const parentEmail = this.signupState.get('parentEmail') + this.signupState.set({ parentEmailSending: true, parentEmailError: false }) + return contact.sendParentSignupInstructions(parentEmail) + .then(() => { + this.signupState.set({ parentEmailSent: true, parentEmailSending: false }) + }) + .catch(() => { + this.signupState.set({ parentEmailError: true, parentEmailSending: false, parentEmailSent: false }) + }) + } + + updateAuthModalInitialValues (values) { + this.signupState.set({ + authModalInitialValues: _.merge(this.signupState.get('authModalInitialValues'), values), + }, { silent: true }) + } + + onChangeEmail (e) { + const email = this.$(e.currentTarget).val() + this.signupState.get('signupForm').email = email + this.updateAuthModalInitialValues({ email }) + return this.checkEmail() + } + + onChangeName (e) { + const name = this.$(e.currentTarget).val() + this.signupState.get('signupForm').name = name + this.updateAuthModalInitialValues({ name }) + return this.checkName() + } + + onChangePassword (e) { + const password = this.$(e.currentTarget).val() + this.signupState.get('signupForm').password = password + this.updateAuthModalInitialValues({ password }) + } + + checkEmail () { + const email = this.$('[name="email"]').val() + + if (this.hideEmail) { + return Promise.resolve(true) + } + + if (!_.isEmpty(email) && email === this.state.get('checkEmailValue')) { + return this.state.get('checkEmailPromise') + } + + if (!(email && forms.validateEmail(email))) { + this.state.set({ + checkEmailState: 'standby', + checkEmailValue: email, + checkEmailPromise: null, + }) + return Promise.resolve() + } + + this.state.set({ + checkEmailState: 'checking', + checkEmailValue: email, + checkEmailPromise: User.checkEmailExists(email) + .then(({ exists }) => { + if (email !== this.$('[name="email"]').val()) { return } + this.state.set({ checkEmailState: exists ? 'exists' : 'available' }) + }) + .catch(error => { + this.state.set({ checkEmailState: 'standby' }) + throw error + }), + }) + return this.state.get('checkEmailPromise') + } + + checkName () { + const name = this.$('[name="name"]').val() + + if (name === this.state.get('checkNameValue')) { + return this.state.get('checkNamePromise') + } + + if (!name) { + this.state.set({ + checkNameState: 'standby', + checkNameValue: name, + checkNamePromise: null, + }) + return Promise.resolve() + } + + this.state.set({ + checkNameState: 'checking', + checkNameValue: name, + checkNamePromise: User.checkNameConflicts(name) + .then(({ suggestedName, conflicts }) => { + if (name !== this.$('[name="name"]').val()) { return } + if (conflicts) { + const suggestedNameText = $.i18n.t('signup.name_taken').replace('{{suggestedName}}', suggestedName) + this.state.set({ checkNameState: 'exists', suggestedName, suggestedNameText }) + } else { + this.state.set({ checkNameState: 'available' }) + } + }) + .catch(error => { + this.state.set({ checkNameState: 'standby' }) + throw error + }), + }) + + return this.state.get('checkNamePromise') + } + + checkBasicInfo (data) { + forms.clearFormAlerts(this.$el) + + if (data.name && forms.validateEmail(data.name)) { + forms.setErrorToProperty(this.$el, 'name', $.i18n.t('signup.name_is_email')) + return false + } + + const res = tv4.validateMultiple(data, this.formSchema()) + if (res.errors && res.errors.some(err => err.dataPath === '/password')) { + res.errors = res.errors.filter(err => err.dataPath !== '/password') + res.errors.push({ dataPath: '/password', message: $.i18n.t('signup.invalid') }) + } + if (!res.valid || ((res.errors != null ? res.errors.length : undefined) > 0)) { + forms.applyErrorsToForm(this.$('form.auth-individual-form'), res.errors) + } + return res.valid && ((res.errors != null ? res.errors.length : undefined) === 0) + } + + formSchema () { + return { + type: 'object', + properties: { + email: User.schema.properties.email, + name: User.schema.properties.name, + password: User.schema.properties.password, + }, + required: ['name', 'password', 'email'], + } + } + + onSubmitIndividualForm (e) { + e.preventDefault() + this.state.unset('error') + const data = forms.formToObject(e.currentTarget) + const valid = this.checkBasicInfo(data) + if (!valid) { return } + + this.displaySignupSubmitting() + const abortError = new Error('abort') + + return this.checkEmail() + .then(() => this.checkName()) + .then(() => { + if (!(this.state.get('checkEmailState') === 'available' && this.state.get('checkNameState') === 'available')) { + throw abortError + } + + const emails = _.assign({}, me.get('emails')) + if (emails.generalNews == null) { emails.generalNews = {} } + if (me.inEU()) { + emails.generalNews.enabled = false + me.set('unsubscribedFromMarketingEmails', true) + } else { + emails.generalNews.enabled = !_.isEmpty(this.state.get('checkEmailValue')) + } + me.set('emails', emails) + + if (!_.isNaN(this.signupState.get('birthday')?.getTime())) { + me.set('birthday', this.signupState.get('birthday').toISOString().slice(0, 7)) + } + me.set(_.omit(this.signupState.get('ssoAttrs') || {}, 'email', 'facebookID', 'gplusID')) + me.set('features', { + ...(me.get('features') || {}), + isNewDashboardActive: true, + }) + const saveReq = me.save() + if (!saveReq) { + throw new Error('Could not save user') + } + return new Promise(saveReq.then) + }) + .then(newUser => { + globalVar.application.tracker.identifyAfterNextPageLoad() + if (!User.isSmokeTestUser({ email: this.signupState.get('signupForm').email })) { + store.dispatch('me/authenticated', newUser) + globalVar.application.tracker.identify() + } + + let signupReq + switch (this.signupState.get('ssoUsed')) { + case 'gplus': { + const { email, gplusID } = this.signupState.get('ssoAttrs') + signupReq = me.signupWithGPlus(data.name, email, gplusID) + break + } + case 'facebook': { + const { email, facebookID } = this.signupState.get('ssoAttrs') + const facebookAccessToken = this.signupState.get('ssoResp')?.authResponse?.accessToken + signupReq = me.signupWithFacebook(data.name, email, facebookID, { facebookAccessToken }) + break + } + case 'schoology': + case 'classlink': + signupReq = me.signupWithOauth2(data.email, { name: data.name }) + break + default: + signupReq = me.signupWithPassword(data.name, data.email, data.password) + } + + return new Promise(signupReq.then) + }) + .then(() => { + globalVar.application.tracker.trackEvent('CreateAccountModal Individual BasicInfoView Submit Success', { category: 'Individuals' }) + window.location.reload() + }) + .catch(error => { + if (error === abortError || error.message === 'abort') { + return this.displaySignupStandingBy() + } + this.displaySignupStandingBy() + if (error.responseJSON?.i18n) { + this.state.set({ error: $.i18n.t(error.responseJSON.i18n) || 'Unknown Error' }) + } else if (error.responseJSON?.message) { + this.state.set({ error: error.responseJSON.message }) + } else { + this.state.set({ error: 'Unknown Error' }) + } + }) + } + + displaySignupSubmitting () { + this.$('#create-individual-account-btn').text($.i18n.t('signup.creating')).attr('disabled', true) + this.$('input').attr('disabled', true) + } + + displaySignupStandingBy () { + this.$('#create-individual-account-btn').text($.i18n.t('login.sign_up')).attr('disabled', false) + this.$('input').attr('disabled', false) + } + + onClickGoogleSignupButton (e) { + return this.onClickSsoSignupButton(e, 'gplus', application.gplusHandler) + } + + onClickFacebookSignupButton (e) { + return this.onClickSsoSignupButton(e, 'facebook', application.facebookHandler) + } + + onClickSchoologySignupButton (e) { + return this.onClickSsoSignupButton(e, 'schoology', application.schoologyHandler) + } + + onClickClasslinkSignupButton (e) { + return this.onClickSsoSignupButton(e, 'classlink', application.classlinkHandler) + } + + onClickSsoSignupButton (e, ssoUsed, handler) { + e.preventDefault() + if (!handler) { + console.error('Unsupported SSO provider', ssoUsed) + return + } + return this.handleSSOConnect(handler, ssoUsed) + } + + handleSSOConnect (handler, ssoUsed) { + if (me.showChinaRegistration()) { return } + return handler.connect({ + context: this, + success (resp) { + if (resp == null) { resp = {} } + return handler.loadPerson({ + resp, + context: this, + success: ssoAttrs => { + this.signupState.set({ ssoAttrs, ssoResp: resp }) + const { email } = ssoAttrs + return User.checkEmailExists(email).then(({ exists }) => { + this.signupState.set({ ssoUsed, email: ssoAttrs.email }) + const autoName = `${ssoAttrs.email.split('@')[0]}+${ssoUsed}` + this.signupState.set('autoName', autoName) + this.signupState.get('signupForm').email = ssoAttrs.email + this.signupState.get('signupForm').name = this.signupState.get('signupForm').name || autoName + this.state.set({ showEmailForm: true }) + if (exists) { + return this.state.set({ error: $.i18n.t('signup.account_exists') }) + } + }) + }, + }) + }, + }) } openRecoverModal (e) { @@ -220,6 +695,8 @@ class AuthView extends RootView { const btn = this.$('#google-login-button') btn.attr('disabled', false) + const signupBtn = this.$('#google-signup-button') + signupBtn.attr('disabled', false) } onClickFacebookLoginButton (e) { @@ -263,6 +740,8 @@ class AuthView extends RootView { const btn = this.$('#facebook-login-btn') btn.attr('disabled', false) + const signupBtn = this.$('#facebook-signup-btn') + signupBtn.attr('disabled', false) return errors.showNotyNetworkError(...arguments) } From 33ecdc335292dd44979445b9a0a911db8f689600 Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Wed, 24 Jun 2026 00:05:43 +0200 Subject: [PATCH 09/27] fix(testing): align docker image with local auth suite Generated with Codex --- development/docker/Dockerfile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/development/docker/Dockerfile b/development/docker/Dockerfile index 3f135086e12..62f1fd214ca 100644 --- a/development/docker/Dockerfile +++ b/development/docker/Dockerfile @@ -9,7 +9,15 @@ ENV PATH $PATH:/root/.rbenv/bin:/root/.rbenv/shims RUN apt-get update && \ apt-get install -y git curl libssl-dev libreadline-dev zlib1g-dev autoconf bison \ build-essential libyaml-dev libncurses5-dev \ - libffi-dev libgdbm-dev && \ + libffi-dev libgdbm-dev xvfb && \ + install -d -m 0755 /etc/apt/keyrings && \ + curl -fsSL https://packages.mozilla.org/apt/repo-signing-key.gpg -o /etc/apt/keyrings/packages.mozilla.org.asc && \ + gpg -n -q --import --import-options import-show /etc/apt/keyrings/packages.mozilla.org.asc | grep -q 35BAA0B33E9EB396F59CA838C0BA5CE6DC6315A3 && \ + echo "deb [signed-by=/etc/apt/keyrings/packages.mozilla.org.asc] https://packages.mozilla.org/apt mozilla main" > /etc/apt/sources.list.d/mozilla.list && \ + printf 'Package: *\nPin: origin packages.mozilla.org\nPin-Priority: 1000\n' > /etc/apt/preferences.d/mozilla && \ + apt-get update && \ + apt-get install -y firefox && \ + rm -r /var/lib/apt/lists/* && \ curl -sL https://github.com/rbenv/rbenv-installer/raw/main/bin/rbenv-installer | bash - RUN rbenv install ${RUBY_VERSION} && \ From 786cc0cad278c99f950c5c351ddb6633384e952f Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Wed, 24 Jun 2026 00:20:13 +0200 Subject: [PATCH 10/27] feat(auth): advance Direction A mobile signup flow Generated with Codex --- .../create-account-modal/basic-info-view.sass | 12 ++- .../choose-account-type-view.sass | 34 ++++++++ .../create-account-modal/coppa-deny-view.sass | 86 +++++++++++++++++-- .../segment-check-view.sass | 17 ++++ .../create-account-modal/basic-info-view.pug | 5 +- .../choose-account-type-view.pug | 8 +- .../create-account-modal/coppa-deny-view.pug | 72 +++++++++------- .../segment-check-view.pug | 3 +- .../core/CreateAccountModal/BasicInfoView.js | 14 ++- .../ChooseAccountTypeView.js | 22 ++--- .../core/CreateAccountModal/CoppaDenyView.js | 34 +++++--- .../CreateAccountModal/CreateAccountModal.js | 4 +- .../CreateAccountModal/SegmentCheckView.js | 16 +++- .../core/CreateAccountModalCoppaFlow.spec.js | 72 ++++++++++++++++ 14 files changed, 323 insertions(+), 76 deletions(-) create mode 100644 test/app/views/core/CreateAccountModalCoppaFlow.spec.js diff --git a/app/styles/modal/create-account-modal/basic-info-view.sass b/app/styles/modal/create-account-modal/basic-info-view.sass index c1cf4ceaba8..cafc7cc5ba7 100644 --- a/app/styles/modal/create-account-modal/basic-info-view.sass +++ b/app/styles/modal/create-account-modal/basic-info-view.sass @@ -35,12 +35,18 @@ margin-bottom: 20px h4 - margin: 0 + margin: 0 0 8px font-size: 28px line-height: 1.15 font-weight: 700 color: #17314d + .individual-flow-subtitle + margin: 0 + font-size: 15px + line-height: 1.5 + color: #35506f + .individual-basic-info-fields display: flex flex-direction: column @@ -267,6 +273,10 @@ &:focus text-decoration: underline + .submit-row .next-button[disabled] + opacity: 0.5 + cursor: not-allowed + @media (max-width: 767px) .individual-flow-panel padding: 0 16px diff --git a/app/styles/modal/create-account-modal/choose-account-type-view.sass b/app/styles/modal/create-account-modal/choose-account-type-view.sass index 51cfb09a284..7bdde14571d 100644 --- a/app/styles/modal/create-account-modal/choose-account-type-view.sass +++ b/app/styles/modal/create-account-modal/choose-account-type-view.sass @@ -44,6 +44,13 @@ gap: 20px justify-content: flex-start + .choose-type-header + display: flex + flex-direction: column + gap: 10px + align-items: center + text-align: center + .choose-type-title margin: 0 color: $navy @@ -54,6 +61,13 @@ @media (max-width: 767px) font-size: 28px + .choose-type-subtitle + margin: 0 + max-width: 460px + color: $gray + font-size: 15px + line-height: 1.45 + .choose-type-grid width: 100% max-width: 760px @@ -76,11 +90,19 @@ gap: 12px align-items: stretch text-align: left + transition: transform 0.12s ease, filter 0.12s ease &:hover, &:focus-visible text-decoration: none + &:hover + transform: translateY(-2px) + filter: brightness(1.02) + + &:active + transform: scale(0.98) + &:focus-visible outline: 3px solid rgba(118, 90, 255, 0.35) outline-offset: 6px @@ -116,3 +138,15 @@ @media (max-width: 767px) font-size: 14px + + .choose-type-footer + margin: 4px 0 0 + text-align: center + color: $gray + font-size: 14px + line-height: 1.5 + + .login-link + color: $navy + font-weight: 700 + text-decoration: underline diff --git a/app/styles/modal/create-account-modal/coppa-deny-view.sass b/app/styles/modal/create-account-modal/coppa-deny-view.sass index 915df8ddbce..d1de7228776 100644 --- a/app/styles/modal/create-account-modal/coppa-deny-view.sass +++ b/app/styles/modal/create-account-modal/coppa-deny-view.sass @@ -1,16 +1,86 @@ #coppa-deny-view - .parent-email-blurb - width: 500px + .individual-flow-panel + width: min(100%, 560px) + padding: 8px 20px 0 + + .individual-flow-progress + display: grid + grid-template-columns: repeat(2, 1fr) + gap: 10px + margin-bottom: 16px + + .individual-flow-progress-step + height: 8px + border-radius: 999px + background: rgb(28 54 87 / 12%) + + &.active + background: linear-gradient(90deg, #f97316 0%, #ea580c 100%) + + .parent-email-card + border-radius: 24px + padding: 28px 24px + background: linear-gradient(180deg, #fffdf6 0%, #fff7d9 100%) + box-shadow: 0 20px 50px rgb(15 23 42 / 12%) + + .individual-flow-heading + margin-bottom: 20px + + h4 + margin: 0 + font-size: 28px + line-height: 1.15 + font-weight: 700 + color: #17314d .parent-email-input-group + margin-bottom: 16px + + .parent-email-blurb, + .parent-email-sent-copy + margin: 0 + font-size: 15px + line-height: 1.55 + color: #35506f + + .parent-email-confirmation display: flex + flex-direction: column align-items: center text-align: center - - .glyphicon - width: 0 - line-height: 40px - font-size: 30px + gap: 14px + + .parent-email-confirmation-mark + display: inline-flex + align-items: center + justify-content: center + width: 72px + height: 72px + border-radius: 50% + background: #e9e8fd + color: #6f5dff + font-size: 28px + + .parent-email-unsaved-link + display: inline-block + margin-top: 10px + text-align: center + color: #5a6b7b + font-size: 14px + font-weight: 500 + text-decoration: underline .error - color: red + color: #c0392b + + @media (max-width: 767px) + .individual-flow-panel + padding: 0 16px + + .parent-email-card + padding: 24px 18px + border-radius: 20px + + .individual-flow-heading + h4 + font-size: 24px diff --git a/app/styles/modal/create-account-modal/segment-check-view.sass b/app/styles/modal/create-account-modal/segment-check-view.sass index 0305b18e6bd..bd160510218 100644 --- a/app/styles/modal/create-account-modal/segment-check-view.sass +++ b/app/styles/modal/create-account-modal/segment-check-view.sass @@ -46,11 +46,24 @@ display: grid grid-template-columns: minmax(0, 1.35fr) minmax(0, 0.85fr) minmax(0, 1fr) gap: 12px + margin-bottom: 18px select width: 100% height: 48px + .under-13-button + display: inline-flex + align-items: center + justify-content: center + padding: 0 + border: none + background: none + color: #6f5dff + font-size: 15px + font-weight: 600 + text-decoration: underline + .class-code-input-group display: flex flex-direction: row @@ -152,6 +165,10 @@ color: #232323 text-decoration: underline + .next-button[disabled] + opacity: 0.5 + cursor: not-allowed + @media (max-width: 767px) .individual-flow-panel padding: 0 16px diff --git a/app/templates/core/create-account-modal/basic-info-view.pug b/app/templates/core/create-account-modal/basic-info-view.pug index 75ca285d65f..a8e63358a23 100644 --- a/app/templates/core/create-account-modal/basic-info-view.pug +++ b/app/templates/core/create-account-modal/basic-info-view.pug @@ -87,6 +87,7 @@ form#basic-info-form.modal-body.basic-info .individual-flow-note(data-i18n="signup.sign_in_to_continue") .individual-flow-heading h4(data-i18n="signup.create_individual_header") + p.individual-flow-subtitle You’re one step away from unlocking the fun of coding! .individual-basic-info-fields if view.utils.isOzaria || !view.hideEmail .form-group @@ -244,7 +245,7 @@ form#basic-info-form.modal-body.basic-info if view.utils.isOzaria +error-area // In reverse order for tabbing purposes - .history-nav-buttons + .history-nav-buttons.submit-row if view.signupState.get('path') === 'teacher' button.next-button.btn.btn-lg(type='submit' class=view.utils.isCodeCombat ? "btn-navy" : "ozaria-primary-button") span(data-i18n="common.next") @@ -252,7 +253,7 @@ form#basic-info-form.modal-body.basic-info button#create-account-btn.next-button.btn.btn-lg(type='submit' class=view.utils.isCodeCombat ? "btn-navy" : "ozaria-primary-button") span(data-i18n="common.continue") else - button#create-account-btn.next-button.btn.btn-lg(type='submit' class=view.utils.isCodeCombat ? "btn-navy" : "ozaria-primary-button") + button#create-account-btn.next-button.btn.btn-lg(type='submit' disabled=view.isSubmitDisabled() class=view.utils.isCodeCombat ? "btn-navy" : "ozaria-primary-button") if view.utils.isCodeCombat span(data-i18n="login.sign_up") else diff --git a/app/templates/core/create-account-modal/choose-account-type-view.pug b/app/templates/core/create-account-modal/choose-account-type-view.pug index 61029fe796f..28c86e04270 100644 --- a/app/templates/core/create-account-modal/choose-account-type-view.pug +++ b/app/templates/core/create-account-modal/choose-account-type-view.pug @@ -7,7 +7,9 @@ mixin account-type-card(type, buttonText, description, imageAlt, imageSRC) if view.utils.isCodeCombat .modal-body-content.choose-account-shell - h2.choose-type-title(data-i18n="nav.create_free_account") + .choose-type-header + h2.choose-type-title(data-i18n="nav.create_free_account") + p.choose-type-subtitle Please select the option that best describes you. if me.showIndividualRegister() .choose-type-grid +account-type-card("teacher-path-button","new_home.im_an_educator", "new_home.im_an_educator_description", '[alt]new_home.for_educators', '/images/pages/account/create/educator.png') @@ -18,6 +20,10 @@ if view.utils.isCodeCombat .choose-type-grid +account-type-card("teacher-path-button","new_home.im_an_educator", "new_home.im_an_educator_description", '[alt]new_home.for_educators', '/images/pages/account/create/educator.png') +account-type-card("student-path-button","new_home.im_a_student", "new_home.im_a_student_description", '[alt]new_home.for_students', '/images/pages/account/create/student.png') + p.choose-type-footer + span Already have an account? + = ' ' + a.login-link(href="#" role="button" tabindex="0") Sign in else form.modal-body.choose-account-type diff --git a/app/templates/core/create-account-modal/coppa-deny-view.pug b/app/templates/core/create-account-modal/coppa-deny-view.pug index 6b2eb0f5f70..df55156fc7d 100644 --- a/app/templates/core/create-account-modal/coppa-deny-view.pug +++ b/app/templates/core/create-account-modal/coppa-deny-view.pug @@ -1,36 +1,44 @@ form.modal-body.coppa-deny .modal-body-content - .parent-email-input-group.form-group - if !view.state.get('parentEmailSent') - label.control-label.text-h4(for="parent-email-input") - span(data-i18n="signup.enter_parent_email") - input#parent-email-input(type="email" name="parentEmail" value=state.get('parentEmail')) - - if state.get('error') - p.small.error - span(data-i18n="signup.parent_email_error") - - p.error(class=(state.get('dontUseOurEmailSilly') ? '' : 'hidden')) - span(data-i18n="signup.dont_use_our_email_silly") - - p.small.parent-email-blurb.render - span - != translate('signup.parent_email_blurb').replace('{{email_link}}', 'team@codecombat.com') + .individual-flow-panel + .individual-flow-progress(aria-hidden="true") + .individual-flow-progress-step.active + .individual-flow-progress-step + .individual-flow-card.parent-email-card + if !view.state.get('parentEmailSent') + .individual-flow-heading + h4 What's your parent's email? + .form-group.parent-email-input-group + label.sr-only(for="parent-email-input") Parent email + input#parent-email-input.form-control.input-lg(type="email" name="parentEmail" placeholder="parent@email.com" value=state.get('parentEmail')) + if state.get('error') + p.small.error + span(data-i18n="signup.parent_email_error") + if state.get('dontUseOurEmailSilly') + p.error + span(data-i18n="signup.dont_use_our_email_silly") + .parent-email-blurb.render + span We’re excited for you to start coding! Your parent will get an email with instructions to create your account. Questions? + = ' ' + a(href="mailto:team@codecombat.com") team@codecombat.com + else + .parent-email-confirmation + .parent-email-confirmation-mark + span.glyphicon.glyphicon-ok + .individual-flow-heading + h4 Check your parent's inbox + p.parent-email-sent-copy + span(data-i18n="signup.parent_email_sent") + p.parent-email-blurb Your parent needs to open the email and finish creating your account before you can save progress. - - if view.state.get('parentEmailSent') - p.small.parent-email-blurb - span(data-i18n="signup.parent_email_sent") - - a.btn.btn-navy.btn-lg(href="/play" data-dismiss="modal") Play without saving - - // In reverse order for tabbing purposes .history-nav-buttons - button.send-parent-email-button.btn.btn-lg.btn-navy(type='submit', disabled=state.get('parentEmailSent') || state.get('parentEmailSending') || state.get('dontUseOurEmailSilly')) - if state.get('parentEmailSent') - span(data-i18n="common.sent") - else - span(data-i18n="common.send") - - button.back-btn.btn.btn-lg.btn-navy-alt(type='button') - span(data-i18n="common.back") + if !state.get('parentEmailSent') + button.send-parent-email-button.btn.btn-lg.btn-navy(type='submit', disabled=state.get('parentEmailSending') || state.get('dontUseOurEmailSilly')) + if state.get('parentEmailSending') + span Sending... + else + span Send to my parent + button.back-btn.btn.btn-lg.btn-navy-alt(type='button') + span(data-i18n="common.back") + else + a.parent-email-unsaved-link(href="/play" data-dismiss="modal") Start playing (unsaved) diff --git a/app/templates/core/create-account-modal/segment-check-view.pug b/app/templates/core/create-account-modal/segment-check-view.pug index d8ca22e5d89..972b85a4cb3 100644 --- a/app/templates/core/create-account-modal/segment-check-view.pug +++ b/app/templates/core/create-account-modal/segment-check-view.pug @@ -111,6 +111,7 @@ form.modal-body.segment-check - var thisYear = new Date().getFullYear() for year in _.range(thisYear, thisYear - 100, -1) option(selected=(year === view.signupState.get('birthdayYear'))) #{year} + button.under-13-button(type='button') I'm under 13 default p(class=view.utils.isOzaria ? "medium-gray" : '') @@ -123,7 +124,7 @@ form.modal-body.segment-check // In reverse order for tabbing purposes .history-nav-buttons //- disabled=!view.signupState.get('segmentCheckValid') - button.next-button.btn.btn-lg(type='submit' class=view.utils.isCodeCombat ? 'btn-navy' : 'ozaria-primary-button') + button.next-button.btn.btn-lg(type='submit' disabled=(view.signupState.get('path') === 'individual' && !state.get('birthdayComplete')) ? true : undefined class=view.utils.isCodeCombat ? 'btn-navy' : 'ozaria-primary-button') span(data-i18n=view.utils.isCodeCombat ? "common.continue" : "common.next") if !view.signupState.get('subModalContinue') diff --git a/app/views/core/CreateAccountModal/BasicInfoView.js b/app/views/core/CreateAccountModal/BasicInfoView.js index 1109e6e1997..101ef270aa9 100644 --- a/app/views/core/CreateAccountModal/BasicInfoView.js +++ b/app/views/core/CreateAccountModal/BasicInfoView.js @@ -88,9 +88,9 @@ module.exports = (BasicInfoView = (function () { isCodeCombat, isOzaria } - this.listenTo(this.state, 'change:checkEmailState', function () { return this.renderSelectors('.email-check') }) - this.listenTo(this.state, 'change:checkNameState', function () { return this.renderSelectors('.name-check') }) - this.listenTo(this.state, 'change:error', function () { return this.renderSelectors('.error-area') }) + this.listenTo(this.state, 'change:checkEmailState', function () { return this.renderSelectors('.email-check, .submit-row') }) + this.listenTo(this.state, 'change:checkNameState', function () { return this.renderSelectors('.name-check, .submit-row') }) + this.listenTo(this.state, 'change:error', function () { return this.renderSelectors('.error-area, .submit-row') }) this.listenTo(this.signupState, 'change:facebookEnabled', function () { return this.renderSelectors('.auth-network-logins') }) this.listenTo(this.signupState, 'change:gplusEnabled', function () { return this.renderSelectors('.auth-network-logins') }) @@ -287,6 +287,14 @@ module.exports = (BasicInfoView = (function () { return this.updateAuthModalInitialValues({ password: this.$(e.currentTarget).val() }) } + isSubmitDisabled () { + const data = forms.formToObject(this.$el.find('#basic-info-form')) + if (this.signupState.get('path') === 'individual') { + return !(data.email && forms.validateEmail(data.email) && data.name && data.password && data.password.length >= 8) + } + return false + } + checkBasicInfo (data) { forms.clearFormAlerts(this.$el) diff --git a/app/views/core/CreateAccountModal/ChooseAccountTypeView.js b/app/views/core/CreateAccountModal/ChooseAccountTypeView.js index a95c4edfc7d..2977930a0e6 100644 --- a/app/views/core/CreateAccountModal/ChooseAccountTypeView.js +++ b/app/views/core/CreateAccountModal/ChooseAccountTypeView.js @@ -22,9 +22,9 @@ module.exports = (ChooseAccountTypeView = (function () { 'click .teacher-path-button' () { return this.trigger('choose-path', utils.isOzaria ? 'teacher' : 'oz-vs-coco') }, 'click .student-path-button' () { return this.trigger('choose-path', 'student') }, 'click .individual-path-button' () { return this.trigger('choose-path', 'individual') }, - 'input .class-code-input': 'onInputClassCode', - 'submit form.choose-account-type': 'onSubmitStudent', 'click .parent-path-button' () { return this.trigger('choose-path', 'individual') }, + 'click .login-link': 'onClickLoginLink', + 'keydown .login-link': 'onKeydownLoginLink', } } @@ -33,19 +33,15 @@ module.exports = (ChooseAccountTypeView = (function () { return this.utils = utils } - getClassCode () { return this.$('.class-code-input').val() || this.signupState.get('classCode') } - - onInputClassCode () { - const classCode = this.getClassCode() - return this.signupState.set({ classCode }, { silent: true }) + onClickLoginLink (e) { + if (e) { e.preventDefault() } + return this.trigger('login') } - onSubmitStudent (e) { - e.preventDefault() - - this.onInputClassCode() - this.trigger('choose-path', 'student') - return false + onKeydownLoginLink (e) { + if (e.key === 'Enter' || e.key === ' ' || e.keyCode === 13 || e.keyCode === 32) { + return this.onClickLoginLink(e) + } } } ChooseAccountTypeView.initClass() diff --git a/app/views/core/CreateAccountModal/CoppaDenyView.js b/app/views/core/CreateAccountModal/CoppaDenyView.js index 82289da7855..2d9f6638643 100644 --- a/app/views/core/CreateAccountModal/CoppaDenyView.js +++ b/app/views/core/CreateAccountModal/CoppaDenyView.js @@ -32,27 +32,37 @@ module.exports = (CoppaDenyView = (function () { if (param == null) { param = {} } const { signupState } = param this.signupState = signupState - this.state = new State({ parentEmail: '' }) + this.state = new State({ + parentEmail: this.signupState.get('parentEmail') || '', + parentEmailSent: false, + parentEmailSending: false, + error: false, + dontUseOurEmailSilly: false, + }) return this.listenTo(this.state, 'all', _.debounce(this.render)) } onChangeParentEmail (e) { const parentEmail = $(e.currentTarget).val() - this.state.set({ parentEmail }, { silent: true }) - if (/team@codecombat.com/i.test(parentEmail)) { - return this.state.set({ dontUseOurEmailSilly: true }) - } else { - return this.state.set({ dontUseOurEmailSilly: false, silent: true }) - } + this.signupState.set({ parentEmail }, { silent: true }) + return this.state.set({ + parentEmail, + dontUseOurEmailSilly: /team@codecombat.com/i.test(parentEmail), + error: false, + }) } onClickSendParentEmailButton (e) { e.preventDefault() - this.state.set({ parentEmailSending: true }) + const parentEmail = this.state.get('parentEmail') + if (!(parentEmail && forms.validateEmail(parentEmail)) || this.state.get('dontUseOurEmailSilly')) { + return this.state.set({ error: true }) + } + this.state.set({ parentEmailSending: true, error: false }) if (window.tracker != null) { - window.tracker.trackEvent('CreateAccountModal Student CoppaDenyView Send Clicked', { category: 'Students' }) + window.tracker.trackEvent('CreateAccountModal Individual Parent Email Send Clicked', { category: 'Individuals' }) } - return contact.sendParentSignupInstructions(this.state.get('parentEmail')) + return contact.sendParentSignupInstructions(parentEmail) .then(() => { return this.state.set({ error: false, parentEmailSent: true, parentEmailSending: false }) }).catch(() => { @@ -61,9 +71,9 @@ module.exports = (CoppaDenyView = (function () { } onClickBackButton () { - if (this.signupState.get('path') === 'student') { + if (this.signupState.get('path') === 'individual') { if (window.tracker != null) { - window.tracker.trackEvent('CreateAccountModal Student CoppaDenyView Back Clicked', { category: 'Students' }) + window.tracker.trackEvent('CreateAccountModal Individual Parent Email Back Clicked', { category: 'Individuals' }) } } return this.trigger('nav-back') diff --git a/app/views/core/CreateAccountModal/CreateAccountModal.js b/app/views/core/CreateAccountModal/CreateAccountModal.js index 6ac4d7e9553..a9cfccf92a7 100644 --- a/app/views/core/CreateAccountModal/CreateAccountModal.js +++ b/app/views/core/CreateAccountModal/CreateAccountModal.js @@ -49,6 +49,7 @@ They `screen`s are: sso-confirm: Alternate version of basic-info for new facebook/g+ users sso-already-exists: When facebook/g+ user already exists, this prompts them to sign in. extras: Not yet implemented + coppa-deny: Individual under-13 parent email handoff and post-send confirmation. confirmation: When an account has been successfully created, this view shows them their info and links them to a landing page based on their account type. @@ -167,7 +168,8 @@ module.exports = (CreateAccountModal = (function () { } return this.signupState.set({ path, screen: 'segment-check' }) } - } + }, + 'login' () { return this.onClickLoginLink() }, }) this.listenTo(this.insertSubView(new SegmentCheckView({ signupState: this.signupState })), { diff --git a/app/views/core/CreateAccountModal/SegmentCheckView.js b/app/views/core/CreateAccountModal/SegmentCheckView.js index af2e9db9a88..e2c50aa2208 100644 --- a/app/views/core/CreateAccountModal/SegmentCheckView.js +++ b/app/views/core/CreateAccountModal/SegmentCheckView.js @@ -28,6 +28,7 @@ module.exports = (SegmentCheckView = (function () { 'change .birthday-form-group': 'onInputBirthday', 'submit form.segment-check': 'onSubmitSegmentCheck', 'click button.play-now': 'onPlayClicked', + 'click .under-13-button': 'onClickUnder13', 'click .individual-path-button' () { return this.trigger('choose-path', 'individual') } } } @@ -41,7 +42,9 @@ module.exports = (SegmentCheckView = (function () { this.fetchAndApplyClassCodeDebounced = _.debounce(this.fetchAndApplyClassCode, 1000) this.fetchClassByCode = _.memoize(this.fetchClassByCode) this.classroom = new Classroom() - this.state = new State() + this.state = new State({ + birthdayComplete: false, + }) if (this.signupState.get('classCode')) { if (utils.isCodeCombat) { this.checkClassCode(this.signupState.get('classCode')) @@ -147,7 +150,9 @@ module.exports = (SegmentCheckView = (function () { onInputBirthday () { const { birthdayYear, birthdayMonth, birthdayDay } = forms.formToObject(this.$('form')) const birthday = new Date(Date.UTC(birthdayYear, birthdayMonth - 1, birthdayDay)) + const birthdayComplete = Boolean(birthdayYear && birthdayMonth && birthdayDay) this.signupState.set({ birthdayYear, birthdayMonth, birthdayDay, birthday }, { silent: true }) + this.state.set({ birthdayComplete }) if (!_.isNaN(birthday.getTime())) { return forms.clearFormAlerts(this.$el) } @@ -161,6 +166,13 @@ module.exports = (SegmentCheckView = (function () { }) } + onClickUnder13 (e) { + if (e) { e.preventDefault() } + this.trackIndividualStepNext('coppa-deny') + this.trigger('nav-forward', 'coppa-deny') + return (window.tracker != null ? window.tracker.trackEvent('CreateAccountModal Individual SegmentCheckView Under 13 Clicked', { category: 'Individuals' }) : undefined) + } + onSubmitSegmentCheck (e) { e.preventDefault() @@ -205,7 +217,7 @@ module.exports = (SegmentCheckView = (function () { } else { this.trackIndividualStepNext('coppa-deny') this.trigger('nav-forward', 'coppa-deny') - return (window.tracker != null ? window.tracker.trackEvent('CreateAccountModal Individual SegmentCheckView Coppa Deny', { category: 'Individuals' }) : undefined) + return (window.tracker != null ? window.tracker.trackEvent('CreateAccountModal Individual SegmentCheckView Parent Email Required', { category: 'Individuals' }) : undefined) } } } diff --git a/test/app/views/core/CreateAccountModalCoppaFlow.spec.js b/test/app/views/core/CreateAccountModalCoppaFlow.spec.js new file mode 100644 index 00000000000..a4f2d52ae5d --- /dev/null +++ b/test/app/views/core/CreateAccountModalCoppaFlow.spec.js @@ -0,0 +1,72 @@ +const SegmentCheckView = require('views/core/CreateAccountModal/SegmentCheckView') +const CoppaDenyView = require('views/core/CreateAccountModal/CoppaDenyView') +const State = require('models/State') +const contact = require('core/contact') + +describe('CreateAccountModal COPPA parent email flow', function () { + let originalTracker + + beforeEach(function () { + originalTracker = window.tracker + window.tracker = { + trackEvent: jasmine.createSpy('trackEvent'), + } + }) + + afterEach(function () { + window.tracker = originalTracker + }) + + it('routes under-13 individuals to the parent email handoff', function () { + const birthday = new Date() + birthday.setFullYear(birthday.getFullYear() - 10) + + const signupState = new State({ + path: 'individual', + birthday, + }) + const view = new SegmentCheckView({ signupState }) + jasmine.spyOn(view, 'trigger') + + view.onSubmitSegmentCheck({ preventDefault () {} }) + + expect(view.trigger).toHaveBeenCalledWith('nav-forward', 'coppa-deny') + expect(window.tracker.trackEvent).toHaveBeenCalledWith( + 'CreateAccountModal Individual SegmentCheckView Parent Email Required', + { category: 'Individuals' }, + ) + }) + + it('marks parent email as sent after successful handoff', function (done) { + const signupState = new State({ path: 'individual' }) + const view = new CoppaDenyView({ signupState }) + jasmine.spyOn(contact, 'sendParentSignupInstructions').and.returnValue(Promise.resolve()) + + view.state.set({ parentEmail: 'parent@example.com' }) + view.onClickSendParentEmailButton({ preventDefault () {} }) + + Promise.resolve().then(() => { + expect(contact.sendParentSignupInstructions).toHaveBeenCalledWith('parent@example.com') + expect(view.state.get('parentEmailSent')).toBe(true) + expect(view.state.get('parentEmailSending')).toBe(false) + expect(window.tracker.trackEvent).toHaveBeenCalledWith( + 'CreateAccountModal Individual Parent Email Send Clicked', + { category: 'Individuals' }, + ) + done() + }) + }) + + it('rejects invalid parent emails before sending', function () { + const signupState = new State({ path: 'individual' }) + const view = new CoppaDenyView({ signupState }) + jasmine.spyOn(contact, 'sendParentSignupInstructions') + + view.state.set({ parentEmail: 'not-an-email' }) + view.onClickSendParentEmailButton({ preventDefault () {} }) + + expect(contact.sendParentSignupInstructions).not.toHaveBeenCalled() + expect(view.state.get('error')).toBe(true) + expect(view.state.get('parentEmailSent')).toBe(false) + }) +}) From a48b6882c2bc2cd560626201f4b14f39a7af2c33 Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Wed, 24 Jun 2026 01:45:03 +0200 Subject: [PATCH 11/27] fix(auth): hard-stop under-13 parent email flow Generated with Codex --- .../modal/create-account-modal/coppa-deny-view.sass | 9 --------- .../core/create-account-modal/coppa-deny-view.pug | 2 -- 2 files changed, 11 deletions(-) diff --git a/app/styles/modal/create-account-modal/coppa-deny-view.sass b/app/styles/modal/create-account-modal/coppa-deny-view.sass index d1de7228776..e8eb03dfd57 100644 --- a/app/styles/modal/create-account-modal/coppa-deny-view.sass +++ b/app/styles/modal/create-account-modal/coppa-deny-view.sass @@ -61,15 +61,6 @@ color: #6f5dff font-size: 28px - .parent-email-unsaved-link - display: inline-block - margin-top: 10px - text-align: center - color: #5a6b7b - font-size: 14px - font-weight: 500 - text-decoration: underline - .error color: #c0392b diff --git a/app/templates/core/create-account-modal/coppa-deny-view.pug b/app/templates/core/create-account-modal/coppa-deny-view.pug index df55156fc7d..612d56d96e3 100644 --- a/app/templates/core/create-account-modal/coppa-deny-view.pug +++ b/app/templates/core/create-account-modal/coppa-deny-view.pug @@ -40,5 +40,3 @@ form.modal-body.coppa-deny span Send to my parent button.back-btn.btn.btn-lg.btn-navy-alt(type='button') span(data-i18n="common.back") - else - a.parent-email-unsaved-link(href="/play" data-dismiss="modal") Start playing (unsaved) From 08dce6db74c00965fb0c14cc48865570b3230b64 Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Wed, 24 Jun 2026 08:42:46 +0200 Subject: [PATCH 12/27] fix(auth): initialize AuthView state before meta binding Generated with Codex --- app/views/account/AuthView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/account/AuthView.js b/app/views/account/AuthView.js index cd2463f14b6..56474b01fa8 100644 --- a/app/views/account/AuthView.js +++ b/app/views/account/AuthView.js @@ -54,7 +54,6 @@ class AuthView extends RootView { } initialize (options = {}) { - super.initialize(options) this.utils = utils this.onFacebookLoginError = this.onFacebookLoginError.bind(this) this.signupState = new State({ @@ -94,6 +93,7 @@ class AuthView extends RootView { }) this.hideEmail = isCodeCombat ? userUtils.shouldHideEmail() : false this.showLibraryIdInsteadOfUsername = isCodeCombat ? userUtils.shouldShowLibraryLoginModal() : false + super.initialize(options) this.listenTo(this.signupState, 'change', () => this.render()) this.listenTo(this.state, 'change', () => this.render()) } From f68ba91af16b07f1bb7316a27b1d7b46eb573727 Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Wed, 24 Jun 2026 09:18:06 +0200 Subject: [PATCH 13/27] feat(auth): add Vue signup and login slice Generated with Codex --- app/core/Router.js | 4 +- app/core/vueRouter.js | 10 + app/views/account/PageAuth.vue | 205 +++++++++++ .../account/components/AuthChooserScreen.vue | 267 ++++++++++++++ .../account/components/AuthLoginScreen.vue | 337 ++++++++++++++++++ .../account/components/AuthWelcomeScreen.vue | 250 +++++++++++++ 6 files changed, 1071 insertions(+), 2 deletions(-) create mode 100644 app/views/account/PageAuth.vue create mode 100644 app/views/account/components/AuthChooserScreen.vue create mode 100644 app/views/account/components/AuthLoginScreen.vue create mode 100644 app/views/account/components/AuthWelcomeScreen.vue diff --git a/app/core/Router.js b/app/core/Router.js index 07ac58b917c..72b29ef5383 100644 --- a/app/core/Router.js +++ b/app/core/Router.js @@ -350,8 +350,8 @@ module.exports = (CocoRouter = (function () { }) }, - signup: go('account/AuthView'), - login: go('account/AuthView'), + signup: go('core/SingletonAppVueComponentView'), + login: go('core/SingletonAppVueComponentView'), parents: go('core/SingletonAppVueComponentView'), 'parents/*path': go('core/SingletonAppVueComponentView'), diff --git a/app/core/vueRouter.js b/app/core/vueRouter.js index 2f20275c03b..cfa1093ad9a 100644 --- a/app/core/vueRouter.js +++ b/app/core/vueRouter.js @@ -57,6 +57,16 @@ export default function getVueRouter () { component: () => import(/* webpackChunkName: "ParentsView" */ 'app/views/landing-pages/parents-v2/PageParents'), meta: { theme: 'teal' }, }, + { + path: '/signup', + props: (route) => ({ mode: 'signup', screen: route.query.screen || 'welcome' }), + component: () => import(/* webpackChunkName: "AccountAuthPage" */ 'app/views/account/PageAuth.vue'), + }, + { + path: '/login', + props: () => ({ mode: 'login', screen: 'login' }), + component: () => import(/* webpackChunkName: "AccountAuthPage" */ 'app/views/account/PageAuth.vue'), + }, { path: '/hackstack', component: () => import(/* webpackChunkName: "HackStackView" */ 'app/views/landing-pages/hackstack/PageHackStack'), diff --git a/app/views/account/PageAuth.vue b/app/views/account/PageAuth.vue new file mode 100644 index 00000000000..ebdc2ef36b3 --- /dev/null +++ b/app/views/account/PageAuth.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/app/views/account/components/AuthChooserScreen.vue b/app/views/account/components/AuthChooserScreen.vue new file mode 100644 index 00000000000..698fc180e44 --- /dev/null +++ b/app/views/account/components/AuthChooserScreen.vue @@ -0,0 +1,267 @@ + + + + + diff --git a/app/views/account/components/AuthLoginScreen.vue b/app/views/account/components/AuthLoginScreen.vue new file mode 100644 index 00000000000..d044f7e0a67 --- /dev/null +++ b/app/views/account/components/AuthLoginScreen.vue @@ -0,0 +1,337 @@ + + + + + diff --git a/app/views/account/components/AuthWelcomeScreen.vue b/app/views/account/components/AuthWelcomeScreen.vue new file mode 100644 index 00000000000..4ee78162496 --- /dev/null +++ b/app/views/account/components/AuthWelcomeScreen.vue @@ -0,0 +1,250 @@ + + + + + From 56cea1853d97be87859ccfd30737f6edd5d7b123 Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Wed, 24 Jun 2026 09:35:56 +0200 Subject: [PATCH 14/27] feat(auth): add solo learner signup slice Generated with Codex --- app/views/account/PageAuth.vue | 177 +++++++++++- .../account/components/AuthBirthdayScreen.vue | 257 ++++++++++++++++++ .../account/components/AuthChooserScreen.vue | 38 ++- .../account/components/AuthCoppaScreen.vue | 122 +++++++++ .../account/components/AuthLoginScreen.vue | 28 +- .../AuthSoloCreateAccountScreen.vue | 182 +++++++++++++ .../account/components/AuthWelcomeScreen.vue | 28 +- 7 files changed, 795 insertions(+), 37 deletions(-) create mode 100644 app/views/account/components/AuthBirthdayScreen.vue create mode 100644 app/views/account/components/AuthCoppaScreen.vue create mode 100644 app/views/account/components/AuthSoloCreateAccountScreen.vue diff --git a/app/views/account/PageAuth.vue b/app/views/account/PageAuth.vue index ebdc2ef36b3..1ef6be0a8ca 100644 --- a/app/views/account/PageAuth.vue +++ b/app/views/account/PageAuth.vue @@ -11,9 +11,36 @@ + + + import AuthWelcomeScreen from './components/AuthWelcomeScreen.vue' import AuthChooserScreen from './components/AuthChooserScreen.vue' +import AuthBirthdayScreen from './components/AuthBirthdayScreen.vue' +import AuthSoloCreateAccountScreen from './components/AuthSoloCreateAccountScreen.vue' +import AuthCoppaScreen from './components/AuthCoppaScreen.vue' import AuthLoginScreen from './components/AuthLoginScreen.vue' const User = require('models/User') const forms = require('core/forms') const errors = require('core/errors') +const contact = require('core/contact') +const utils = require('core/utils') const { me } = require('core/auth') const { logInWithClever } = require('core/social-handlers/CleverHandler') @@ -44,6 +76,9 @@ export default Vue.extend({ components: { AuthWelcomeScreen, AuthChooserScreen, + AuthBirthdayScreen, + AuthSoloCreateAccountScreen, + AuthCoppaScreen, AuthLoginScreen, }, props: { @@ -61,6 +96,18 @@ export default Vue.extend({ submitting: false, googleLoading: false, errorMessage: '', + successMessage: '', + birthday: { + month: '', + day: '', + year: '', + }, + soloCreateForm: { + username: '', + email: '', + password: '', + }, + parentEmail: '', } }, computed: { @@ -71,6 +118,12 @@ export default Vue.extend({ return this.screen || 'welcome' }, }, + mounted () { + document.body.classList.add('auth-route-standalone') + }, + beforeDestroy () { + document.body.classList.remove('auth-route-standalone') + }, methods: { updateRoute (path, query = {}) { if (this.$route.path === path && JSON.stringify(this.$route.query) === JSON.stringify(query)) { @@ -78,29 +131,71 @@ export default Vue.extend({ } this.$router.push({ path, query }).catch(() => {}) }, - goToWelcome () { + resetMessages () { this.errorMessage = '' + this.successMessage = '' + }, + goToWelcome () { + this.resetMessages() this.updateRoute('/signup') }, goToChooser () { - this.errorMessage = '' + this.resetMessages() this.updateRoute('/signup', { screen: 'chooser' }) }, + goToBirthday () { + this.resetMessages() + this.updateRoute('/signup', { screen: 'birthday' }) + }, + goToCreateAccount () { + this.resetMessages() + this.updateRoute('/signup', { screen: 'create-account' }) + }, + goToCoppa () { + this.resetMessages() + this.updateRoute('/signup', { screen: 'coppa' }) + }, goToLogin () { - this.errorMessage = '' + this.resetMessages() this.updateRoute('/login') }, + handleChooserPath (path) { + if (path === 'individual') { + return this.goToBirthday() + } + return this.onSelectPlaceholder(path) + }, onSelectPlaceholder (path) { const titles = { educator: 'Educator path arrives in next slice.', parent: 'Parent path arrives in next slice.', classroom: 'With a Class path arrives in next slice.', - individual: 'Solo Learner path arrives in next slice.', } noty({ text: titles[path] || 'Next step arrives in next slice.', layout: 'topCenter', type: 'info', timeout: 3000, killer: false, dismissQueue: true }) }, - submitLogin ({ username, password }) { + handleBirthdayContinue (birthday) { + this.birthday = { ...birthday } + const birthDate = new Date(Date.UTC(Number(birthday.year), Number(birthday.month) - 1, Number(birthday.day))) + const age = (new Date().getTime() - birthDate.getTime()) / 365.4 / 24 / 60 / 60 / 1000 + if (_.isNaN(birthDate.getTime())) { + this.errorMessage = 'Please complete your birthday.' + return + } + if (age > utils.ageOfConsent(me.get('country'), 13)) { + this.goToCreateAccount() + } else { + this.goToCoppa() + } + }, + updateSoloCreateForm (form) { + this.soloCreateForm = { ...form } + }, + updateParentEmail (value) { + this.parentEmail = value this.errorMessage = '' + }, + submitLogin ({ username, password }) { + this.resetMessages() this.submitting = true return me.loginPasswordUser(username, password, { success: () => { @@ -120,8 +215,57 @@ export default Vue.extend({ this.submitting = false }) }, + submitSoloCreateAccount ({ username, email, password }) { + this.resetMessages() + if (password.length < 8) { + this.errorMessage = 'Use at least 8 characters for your password.' + return + } + this.submitting = true + return me.signupWithPassword(username, email, password, { + success: () => { + window.location.href = '/' + }, + error: (res, jqxhr = {}) => { + const errorID = jqxhr.responseJSON?.errorID + if (errorID === 'email-exists') { + this.errorMessage = 'An account already uses that email.' + } else if (errorID === 'name-exists') { + this.errorMessage = 'That username is already taken.' + } else { + this.errorMessage = 'Create account is not fully wired yet. Please try again.' + errors.showNotyNetworkError(res, jqxhr) + } + }, + }).always(() => { + this.submitting = false + }) + }, + signupWithGoogle () { + this.resetMessages() + noty({ text: 'Google signup wiring is next pass; password signup is live now.', layout: 'topCenter', type: 'info', timeout: 3500, killer: false, dismissQueue: true }) + }, + submitParentEmail (email) { + this.resetMessages() + if (!(email && forms.validateEmail(email)) || /team@codecombat.com/i.test(email)) { + this.errorMessage = 'Enter a valid parent email.' + return + } + this.submitting = true + this.parentEmail = email + return contact.sendParentSignupInstructions(email) + .then(() => { + this.successMessage = 'Check your parent\'s inbox. They need to finish creating your account before you can save progress.' + }) + .catch(() => { + this.errorMessage = 'We could not send that email yet. Please try again.' + }) + .finally(() => { + this.submitting = false + }) + }, loginWithGoogle () { - this.errorMessage = '' + this.resetMessages() this.googleLoading = true forms.clearFormAlerts?.($(this.$el)) return application.gplusHandler.connect({ @@ -172,6 +316,23 @@ export default Vue.extend({ }) + + diff --git a/app/views/account/components/AuthChooserScreen.vue b/app/views/account/components/AuthChooserScreen.vue index 698fc180e44..89733fd2d50 100644 --- a/app/views/account/components/AuthChooserScreen.vue +++ b/app/views/account/components/AuthChooserScreen.vue @@ -2,11 +2,11 @@
- CodeCombat +
@@ -61,6 +61,8 @@ + + diff --git a/app/views/account/components/AuthLoginScreen.vue b/app/views/account/components/AuthLoginScreen.vue index d044f7e0a67..92787b9e441 100644 --- a/app/views/account/components/AuthLoginScreen.vue +++ b/app/views/account/components/AuthLoginScreen.vue @@ -2,11 +2,11 @@