diff --git a/app/components/header.hbs b/app/components/header.hbs index 9c01192ff..f0d22f178 100644 --- a/app/components/header.hbs +++ b/app/components/header.hbs @@ -61,6 +61,16 @@ >Live {{/if}} + {{#if (and @dev this.isSuperUser)}} +
  • + Applications +
  • + {{/if}} diff --git a/app/components/header.js b/app/components/header.js index d29619136..053860638 100644 --- a/app/components/header.js +++ b/app/components/header.js @@ -7,6 +7,7 @@ import { inject as service } from '@ember/service'; export default class HeaderComponent extends Component { @service router; @service fastboot; + @service login; @tracked isNavOpen = false; @tracked isMenuOpen = false; @tracked authURL = this.generateAuthURL(); @@ -24,6 +25,10 @@ export default class HeaderComponent extends Component { IDENTITY_URL = APPS.IDENTITY; MY_STATUS_URL = APPS.MY_STATUS; + get isSuperUser() { + return this.login.userData?.roles?.super_user || false; + } + @action toggleNavbar() { this.isNavOpen = !this.isNavOpen; } diff --git a/app/components/new-join-steps/base-step.js b/app/components/new-join-steps/base-step.js new file mode 100644 index 000000000..2f9578aef --- /dev/null +++ b/app/components/new-join-steps/base-step.js @@ -0,0 +1,128 @@ +import { action } from '@ember/object'; +import { debounce } from '@ember/runloop'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { JOIN_DEBOUNCE_TIME } from '../../constants/join'; +import { validateWordCount } from '../../utils/validator'; +import { scheduleOnce } from '@ember/runloop'; +import { getLocalStorageItem, setLocalStorageItem } from '../../utils/storage'; + +export default class BaseStepComponent extends Component { + stepValidation = {}; + + @tracked data = {}; + @tracked errorMessage = {}; + @tracked wordCount = {}; + + get storageKey() { + return ''; + } + + postLoadInitialize() {} + + constructor(...args) { + super(...args); + scheduleOnce('afterRender', this, this.initializeFormState); + } + + initializeFormState() { + let saved = {}; + try { + const stored = getLocalStorageItem(this.storageKey, '{}'); + saved = stored ? JSON.parse(stored) : {}; + } catch (e) { + console.warn('Failed to parse stored form data:', e); + saved = {}; + } + this.data = saved; + + this.errorMessage = Object.fromEntries( + Object.keys(this.stepValidation).map((k) => [k, '']), + ); + + this.wordCount = Object.fromEntries( + Object.keys(this.stepValidation).map((k) => { + let val = String(this.data[k] || ''); + return [k, val.trim().split(/\s+/).filter(Boolean).length || 0]; + }), + ); + + this.postLoadInitialize(); + + const valid = this.isDataValid(); + this.args.setIsPreValid(valid); + setLocalStorageItem('isValid', String(valid)); + } + + @action inputHandler(e) { + if (!e?.target) return; + this.args.setIsPreValid(false); + const field = e.target.name; + const value = e.target.value; + debounce(this, this.handleFieldUpdate, field, value, JOIN_DEBOUNCE_TIME); + } + + validateField(field, value) { + const limits = this.stepValidation[field]; + const fieldType = limits?.type || 'text'; + + if (fieldType === 'select' || fieldType === 'dropdown') { + const hasValue = value && String(value).trim().length > 0; + return { isValid: hasValue }; + } + return validateWordCount(value, limits); + } + + isDataValid() { + for (const field of Object.keys(this.stepValidation)) { + const result = this.validateField(field, this.data[field]); + if (!result.isValid) return false; + } + return true; + } + + handleFieldUpdate(field, value) { + this.updateFieldValue(field, value); + const result = this.validateField(field, value); + this.updateWordCount(field, result); + this.updateErrorMessage(field, result); + this.syncFormValidity(); + } + + updateFieldValue(field, value) { + this.data = { ...this.data, [field]: value }; + setLocalStorageItem(this.storageKey, JSON.stringify(this.data)); + } + + updateWordCount(field, result) { + const wordCount = result.wordCount ?? 0; + this.wordCount = { ...this.wordCount, [field]: wordCount }; + } + + updateErrorMessage(field, result) { + this.errorMessage = { + ...this.errorMessage, + [field]: this.formatError(field, result), + }; + } + + formatError(field, result) { + const limits = this.stepValidation[field]; + if (result.isValid) return ''; + + const fieldType = limits?.type || 'text'; + if (fieldType === 'select' || fieldType === 'dropdown') { + return 'Please choose an option'; + } + if (result.remainingToMin) { + return `At least ${result.remainingToMin} more word(s) required`; + } + return `Maximum ${limits?.max ?? 'N/A'} words allowed`; + } + + syncFormValidity() { + const allValid = this.isDataValid(); + this.args.setIsValid(allValid); + setLocalStorageItem('isValid', String(allValid)); + } +} diff --git a/app/components/new-join-steps/new-step-five.hbs b/app/components/new-join-steps/new-step-five.hbs new file mode 100644 index 000000000..ded36fdc4 --- /dev/null +++ b/app/components/new-join-steps/new-step-five.hbs @@ -0,0 +1,33 @@ +
    +
    +

    {{@heading}}

    +

    {{@subHeading}}

    +
    + + + {{#if this.errorMessage.whyRds}} +
    {{this.errorMessage.whyRds}}
    + {{/if}} + +
    +
    + + {{#if this.errorMessage.numberOfHours}} +
    {{this.errorMessage.numberOfHours}}
    + {{/if}} +
    + +
    + + {{#if this.errorMessage.foundFrom}} +
    {{this.errorMessage.foundFrom}}
    + {{/if}} +
    +
    +
    \ No newline at end of file diff --git a/app/components/new-join-steps/new-step-five.js b/app/components/new-join-steps/new-step-five.js new file mode 100644 index 000000000..71a353264 --- /dev/null +++ b/app/components/new-join-steps/new-step-five.js @@ -0,0 +1,16 @@ +import BaseStepComponent from './base-step'; +import { + NEW_STEP_LIMITS, + STEP_DATA_STORAGE_KEY, +} from '../../constants/new-join-form'; +import { heardFrom } from '../../constants/social-data'; + +export default class NewStepFiveComponent extends BaseStepComponent { + storageKey = STEP_DATA_STORAGE_KEY.stepFive; + heardFrom = heardFrom; + + stepValidation = { + whyRds: NEW_STEP_LIMITS.stepFive.whyRds, + foundFrom: NEW_STEP_LIMITS.stepFive.foundFrom, + }; +} diff --git a/app/components/new-join-steps/new-step-four.hbs b/app/components/new-join-steps/new-step-four.hbs new file mode 100644 index 000000000..40106a442 --- /dev/null +++ b/app/components/new-join-steps/new-step-four.hbs @@ -0,0 +1,62 @@ +
    +
    +

    {{@heading}}

    +

    {{@subHeading}}

    +
    + + + {{#if this.errorMessage.phoneNumber}} +
    {{this.errorMessage.phoneNumber}}
    + {{/if}} + + + {{#if this.errorMessage.twitter}} +
    {{this.errorMessage.twitter}}
    + {{/if}} + + {{#if this.showGitHub}} + + {{#if this.errorMessage.github}} +
    {{this.errorMessage.github}}
    + {{/if}} + {{/if}} + + + {{#if this.errorMessage.linkedin}} +
    {{this.errorMessage.linkedin}}
    + {{/if}} + + + {{#if this.errorMessage.instagram}} +
    {{this.errorMessage.instagram}}
    + {{/if}} + + + {{#if this.errorMessage.peerlist}} +
    {{this.errorMessage.peerlist}}
    + {{/if}} + + {{#if this.showBehance}} + + {{#if this.errorMessage.behance}} +
    {{this.errorMessage.behance}}
    + {{/if}} + {{/if}} + + {{#if this.showDribble}} + + {{#if this.errorMessage.dribble}} +
    {{this.errorMessage.dribble}}
    + {{/if}} + {{/if}} +
    \ No newline at end of file diff --git a/app/components/new-join-steps/new-step-four.js b/app/components/new-join-steps/new-step-four.js new file mode 100644 index 000000000..bae007588 --- /dev/null +++ b/app/components/new-join-steps/new-step-four.js @@ -0,0 +1,80 @@ +import BaseStepComponent from './base-step'; +import { + NEW_STEP_LIMITS, + STEP_DATA_STORAGE_KEY, +} from '../../constants/new-join-form'; +import { phoneNumberRegex } from '../../constants/regex'; + +export default class NewStepFourComponent extends BaseStepComponent { + storageKey = STEP_DATA_STORAGE_KEY.stepFour; + + stepValidation = { + phoneNumber: NEW_STEP_LIMITS.stepFour.phoneNumber, + twitter: NEW_STEP_LIMITS.stepFour.twitter, + linkedin: NEW_STEP_LIMITS.stepFour.linkedin, + instagram: NEW_STEP_LIMITS.stepFour.instagram, + peerlist: NEW_STEP_LIMITS.stepFour.peerlist, + }; + + get userRole() { + const stepOneData = JSON.parse( + localStorage.getItem('newStepOneData') || '{}', + ); + return stepOneData.role || ''; + } + + postLoadInitialize() { + if (this.userRole === 'Developer') { + this.stepValidation.github = NEW_STEP_LIMITS.stepFour.github; + } + + if (this.userRole === 'Designer') { + this.stepValidation.behance = NEW_STEP_LIMITS.stepFour.behance; + this.stepValidation.dribble = NEW_STEP_LIMITS.stepFour.dribble; + } + + // re-calculate the errorMessage and wordCount for new input fields + this.errorMessage = Object.fromEntries( + Object.keys(this.stepValidation).map((k) => [k, '']), + ); + + this.wordCount = Object.fromEntries( + Object.keys(this.stepValidation).map((k) => { + let val = this.data[k] || ''; + return [k, val.trim().split(/\s+/).filter(Boolean).length || 0]; + }), + ); + } + + get showGitHub() { + return this.userRole === 'Developer'; + } + + get showBehance() { + return this.userRole === 'Designer'; + } + + get showDribble() { + return this.userRole === 'Designer'; + } + + validateField(field, value) { + if (field === 'phoneNumber') { + const trimmedValue = value?.trim() || ''; + const isValid = trimmedValue && phoneNumberRegex.test(trimmedValue); + return { + isValid, + wordCount: 0, + }; + } + return super.validateField(field, value); + } + + formatError(field, result) { + if (field === 'phoneNumber') { + if (result.isValid) return ''; + return 'Please enter a valid phone number (e.g., +91 80000 00000)'; + } + return super.formatError(field, result); + } +} diff --git a/app/components/new-join-steps/new-step-one.hbs b/app/components/new-join-steps/new-step-one.hbs new file mode 100644 index 000000000..bbe08122d --- /dev/null +++ b/app/components/new-join-steps/new-step-one.hbs @@ -0,0 +1,65 @@ +
    +
    +

    Profile Picture

    + {{#if this.isImageUploading}} +
    + +

    Processing image...

    +
    + {{else}} + {{#if this.imagePreview}} +
    + Profile preview + +
    + {{else}} + + {{/if}} + {{/if}} + +
    +

    Image Requirements:

    +
      +
    • Must be a real, clear photograph (no anime, filters, or drawings)
    • +
    • Must contain exactly one face
    • +
    • Face must cover at least 60% of the image
    • +
    • Supported formats: JPG, PNG
    • +
    • Image will be validated before moving to next step
    • +
    +
    +
    + +
    +

    Personal Details

    +

    Please provide correct details and choose your role carefully, it won't be changed + later.

    + + + + + + + + + +
    +

    Applying as

    +
    + {{#each this.roleOptions as |role|}} + + {{/each}} +
    +
    +
    +
    \ No newline at end of file diff --git a/app/components/new-join-steps/new-step-one.js b/app/components/new-join-steps/new-step-one.js new file mode 100644 index 000000000..86b3d7207 --- /dev/null +++ b/app/components/new-join-steps/new-step-one.js @@ -0,0 +1,104 @@ +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { countryList } from '../../constants/country-list'; +import { + NEW_STEP_LIMITS, + ROLE_OPTIONS, + STEP_DATA_STORAGE_KEY, +} from '../../constants/new-join-form'; +import BaseStepComponent from './base-step'; + +export default class NewStepOneComponent extends BaseStepComponent { + @service login; + @service toast; + + roleOptions = ROLE_OPTIONS; + countries = countryList; + + @tracked imagePreview = null; + @tracked isImageUploading = false; + @tracked fileInputElement = null; + + get storageKey() { + return STEP_DATA_STORAGE_KEY.stepOne; + } + + stepValidation = { + country: NEW_STEP_LIMITS.stepOne.country, + state: NEW_STEP_LIMITS.stepOne.state, + city: NEW_STEP_LIMITS.stepOne.city, + role: NEW_STEP_LIMITS.stepOne.role, + }; + + postLoadInitialize() { + if ( + !this.data.fullName && + this.login.userData?.first_name && + this.login.userData?.last_name + ) { + this.updateFieldValue( + 'fullName', + `${this.login.userData.first_name} ${this.login.userData.last_name}`, + ); + } + if (this.data.profileImageBase64) { + this.imagePreview = this.data.profileImageBase64; + } + } + + @action selectRole(role) { + this.inputHandler({ target: { name: 'role', value: role } }); + } + + @action + setFileInputElement(element) { + this.fileInputElement = element; + } + + @action + clearFileInputElement() { + this.fileInputElement = null; + } + + @action + triggerFileInput() { + this.fileInputElement?.click(); + } + + @action + handleImageSelect(event) { + const file = event.target.files?.[0]; + if (!file || !file.type.startsWith('image/')) { + this.toast.error( + 'Invalid file type. Please upload an image file.', + 'Error!', + ); + return; + } + const maxSize = 2 * 1024 * 1024; + if (file.size > maxSize) { + this.toast.error('Image size must be less than 2MB', 'Error!'); + return; + } + + this.isImageUploading = true; + + const reader = new FileReader(); + reader.onload = (e) => { + const base64String = e.target.result; + this.imagePreview = base64String; + this.updateFieldValue?.('profileImageBase64', base64String); + this.isImageUploading = false; + }; + reader.onerror = () => { + this.toast.error( + 'Failed to read the selected file. Please try again.', + 'Error!', + ); + this.isImageUploading = false; + }; + + reader.readAsDataURL(file); + } +} diff --git a/app/components/new-join-steps/new-step-six.hbs b/app/components/new-join-steps/new-step-six.hbs new file mode 100644 index 000000000..cb18d7d54 --- /dev/null +++ b/app/components/new-join-steps/new-step-six.hbs @@ -0,0 +1,207 @@ +
    +
    +

    {{@heading}}

    +

    {{@subHeading}}

    +
    + +
    +
    +

    Personal Information

    + +
    +
    +
    + Full Name: + + {{if this.stepData.one.fullName this.stepData.one.fullName 'Not provided'}} + +
    +
    + Location: + + {{this.locationDisplay}} + +
    +
    + Applying as: + + {{if this.stepData.one.role this.stepData.one.role 'Not provided'}} + +
    +
    + Profile Image: + + Not uploaded + +
    +
    +
    + +
    +
    +

    Professional Details

    + +
    +
    +
    + Skills: + + {{if this.stepData.two.skills this.stepData.two.skills 'Not provided'}} + +
    +
    + Institution/Company: + + {{if this.stepData.two.company this.stepData.two.company 'Not provided'}} + +
    +
    + Introduction: + + {{if this.stepData.two.introduction this.stepData.two.introduction 'Not provided'}} + +
    +
    +
    + +
    +
    +

    Hobbies & Interests

    + +
    +
    +
    + Hobbies: + + {{if this.stepData.three.hobbies this.stepData.three.hobbies 'Not provided'}} + +
    +
    + Fun Fact: + + {{if this.stepData.three.funFact this.stepData.three.funFact 'Not provided'}} + +
    +
    +
    + +
    +
    +

    Social Profiles

    + +
    +
    +
    + Phone Number: + + {{if this.stepData.four.phoneNumber this.stepData.four.phoneNumber 'Not provided'}} + +
    +
    + Twitter: + + {{if this.stepData.four.twitter this.stepData.four.twitter 'Not provided'}} + +
    + {{#if this.showGitHub}} +
    + GitHub: + + {{if this.stepData.four.github this.stepData.four.github 'Not provided'}} + +
    + {{/if}} +
    + LinkedIn: + + {{if this.stepData.four.linkedin this.stepData.four.linkedin 'Not provided'}} + +
    +
    + Instagram: + + {{if this.stepData.four.instagram this.stepData.four.instagram 'Not uploaded'}} + +
    +
    + Peerlist: + + {{if this.stepData.four.peerlist this.stepData.four.peerlist 'Not provided'}} + +
    + {{#if this.showBehance}} +
    + Behance: + + {{if this.stepData.four.behance this.stepData.four.behance 'Not provided'}} + +
    + {{/if}} + {{#if this.showDribble}} +
    + Dribble: + + {{if this.stepData.four.dribble this.stepData.four.dribble 'Not provided'}} + +
    + {{/if}} +
    +
    + +
    +
    +

    Why Real Dev Squad?

    + +
    +
    +
    + Why you want to join Real Dev Squad?: + + {{if this.stepData.five.whyRds this.stepData.five.whyRds 'Not provided'}} + +
    +
    + Hours per week: + + {{if this.stepData.five.numberOfHours this.stepData.five.numberOfHours 'Not provided'}} + +
    +
    + How did you hear about us?: + + {{if this.stepData.five.foundFrom this.stepData.five.foundFrom 'Not provided'}} + +
    +
    +
    +
    diff --git a/app/components/new-join-steps/new-step-six.js b/app/components/new-join-steps/new-step-six.js new file mode 100644 index 000000000..9928e6631 --- /dev/null +++ b/app/components/new-join-steps/new-step-six.js @@ -0,0 +1,57 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { getLocalStorageItem } from '../../utils/storage'; +import { STEP_DATA_STORAGE_KEY } from '../../constants/new-join-form'; + +export default class NewStepSixComponent extends Component { + @tracked stepData = { + one: {}, + two: {}, + three: {}, + four: {}, + five: {}, + }; + + constructor(...args) { + super(...args); + this.loadAllStepData(); + } + + loadAllStepData() { + this.stepData.one = JSON.parse( + getLocalStorageItem(STEP_DATA_STORAGE_KEY.stepOne), + ); + this.stepData.two = JSON.parse( + getLocalStorageItem(STEP_DATA_STORAGE_KEY.stepTwo), + ); + this.stepData.three = JSON.parse( + getLocalStorageItem(STEP_DATA_STORAGE_KEY.stepThree), + ); + this.stepData.four = JSON.parse( + getLocalStorageItem(STEP_DATA_STORAGE_KEY.stepFour), + ); + this.stepData.five = JSON.parse( + getLocalStorageItem(STEP_DATA_STORAGE_KEY.stepFive), + ); + } + + get userRole() { + return this.stepData.one.role || ''; + } + + get showGitHub() { + return this.userRole === 'Developer'; + } + + get showBehance() { + return this.userRole === 'Designer'; + } + + get showDribble() { + return this.userRole === 'Designer'; + } + + get locationDisplay() { + return `${this.stepData.one.city}, ${this.stepData.one.state}, ${this.stepData.one.country}`; + } +} diff --git a/app/components/new-join-steps/new-step-three.hbs b/app/components/new-join-steps/new-step-three.hbs new file mode 100644 index 000000000..7614c45f2 --- /dev/null +++ b/app/components/new-join-steps/new-step-three.hbs @@ -0,0 +1,38 @@ +
    +
    +

    {{@heading}}

    +

    {{@subHeading}}

    +
    + +
    +
    + +
    {{this.wordCount.hobbies}}/{{this.stepValidation.hobbies.max}} words
    + {{#if this.errorMessage.hobbies}} +
    {{this.errorMessage.hobbies}}
    + {{/if}} +
    + +
    + +
    {{this.wordCount.funFact}}/{{this.stepValidation.funFact.max}} words
    + {{#if this.errorMessage.funFact}} +
    {{this.errorMessage.funFact}}
    + {{/if}} +
    +
    +
    \ No newline at end of file diff --git a/app/components/new-join-steps/new-step-three.js b/app/components/new-join-steps/new-step-three.js new file mode 100644 index 000000000..0c3fd6b25 --- /dev/null +++ b/app/components/new-join-steps/new-step-three.js @@ -0,0 +1,13 @@ +import BaseStepComponent from './base-step'; +import { + NEW_STEP_LIMITS, + STEP_DATA_STORAGE_KEY, +} from '../../constants/new-join-form'; + +export default class NewStepThreeComponent extends BaseStepComponent { + storageKey = STEP_DATA_STORAGE_KEY.stepThree; + stepValidation = { + hobbies: NEW_STEP_LIMITS.stepThree.hobbies, + funFact: NEW_STEP_LIMITS.stepThree.funFact, + }; +} diff --git a/app/components/new-join-steps/new-step-two.hbs b/app/components/new-join-steps/new-step-two.hbs new file mode 100644 index 000000000..c96c5175e --- /dev/null +++ b/app/components/new-join-steps/new-step-two.hbs @@ -0,0 +1,37 @@ +
    +
    +

    {{@heading}}

    +

    {{@subHeading}}

    +
    + +
    +
    +
    + + {{#if this.errorMessage.skills}} +
    {{this.errorMessage.skills}}
    + {{/if}} +
    + +
    + + {{#if this.errorMessage.company}} +
    {{this.errorMessage.company}}
    + {{/if}} +
    +
    +
    + +
    {{this.wordCount.introduction}}/{{this.stepValidation.introduction.max}} words
    + + {{#if this.errorMessage.introduction}} +
    {{this.errorMessage.introduction}}
    + {{/if}} +
    +
    +
    \ No newline at end of file diff --git a/app/components/new-join-steps/new-step-two.js b/app/components/new-join-steps/new-step-two.js new file mode 100644 index 000000000..edb382940 --- /dev/null +++ b/app/components/new-join-steps/new-step-two.js @@ -0,0 +1,14 @@ +import BaseStepComponent from './base-step'; +import { + NEW_STEP_LIMITS, + STEP_DATA_STORAGE_KEY, +} from '../../constants/new-join-form'; + +export default class NewStepTwoComponent extends BaseStepComponent { + storageKey = STEP_DATA_STORAGE_KEY.stepTwo; + stepValidation = { + skills: NEW_STEP_LIMITS.stepTwo.skills, + company: NEW_STEP_LIMITS.stepTwo.company, + introduction: NEW_STEP_LIMITS.stepTwo.introduction, + }; +} diff --git a/app/components/new-join-steps/stepper-header.hbs b/app/components/new-join-steps/stepper-header.hbs new file mode 100644 index 000000000..7fcd94380 --- /dev/null +++ b/app/components/new-join-steps/stepper-header.hbs @@ -0,0 +1,14 @@ +
    +
    +
    +

    RDS Application Form

    +
    +
    + {{@currentStep}} + of {{@totalSteps}} +
    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/app/components/new-join-steps/stepper-header.js b/app/components/new-join-steps/stepper-header.js new file mode 100644 index 000000000..77ce058e9 --- /dev/null +++ b/app/components/new-join-steps/stepper-header.js @@ -0,0 +1,17 @@ +import Component from '@glimmer/component'; +import { htmlSafe } from '@ember/template'; +import { cached } from '@glimmer/tracking'; + +export default class StepperHeaderComponent extends Component { + @cached + get progressPercentage() { + const totalSteps = Number(this.args.totalSteps) || 1; + const currentStep = Number(this.args.currentStep) || 0; + return Math.min(100, Math.round((currentStep / totalSteps) * 100)); + } + + @cached + get progressStyle() { + return htmlSafe(`width: ${this.progressPercentage}%`); + } +} diff --git a/app/components/new-join-steps/thank-you-screen.hbs b/app/components/new-join-steps/thank-you-screen.hbs new file mode 100644 index 000000000..b014fad5c --- /dev/null +++ b/app/components/new-join-steps/thank-you-screen.hbs @@ -0,0 +1,33 @@ +
    + + +
    +

    {{@firstName}}, thank you for applying to RDS.

    +

    Great work filling up the application. However, it takes more to join us early.

    +
    + +
    +
    + Head over to Application Tracking Page. +
    + +
    + Checkout AI review and and edit your application to improve application rank. +
    + +
    + Complete quests to improve your ranking and increase your chances of early reviews. +
    +
    + +
    +

    Application ID

    +

    {{@applicationId}}

    +
    + +
    + +
    +
    \ No newline at end of file diff --git a/app/components/new-join-steps/welcome-screen.hbs b/app/components/new-join-steps/welcome-screen.hbs new file mode 100644 index 000000000..df6c232b0 --- /dev/null +++ b/app/components/new-join-steps/welcome-screen.hbs @@ -0,0 +1,45 @@ +
    + + +

    Ready to apply to Real Dev Squad?

    + +
    +
    + This form should take up to 10 mins to fill. +
    + +
    + Have your professional headshot ready before you begin. +
    + +
    + Once you're done, you'll get access to quests that help improve your rank and increase your chances of selection. +
    +
    + + +
    + +{{#if this.isTermsModalOpen}} + +{{/if}} \ No newline at end of file diff --git a/app/components/new-join-steps/welcome-screen.js b/app/components/new-join-steps/welcome-screen.js new file mode 100644 index 000000000..8537b06ba --- /dev/null +++ b/app/components/new-join-steps/welcome-screen.js @@ -0,0 +1,30 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class WelcomeScreenComponent extends Component { + @service joinApplicationTerms; + @tracked isTermsModalOpen = false; + + @action + handleTermsCheckboxChange(e) { + if (this.joinApplicationTerms.hasUserAcceptedTerms) { + this.joinApplicationTerms.setTermsAccepted(false); + } else { + e.preventDefault(); + this.isTermsModalOpen = true; + } + } + + @action + closeTermsModal() { + this.isTermsModalOpen = false; + } + + @action + acceptTerms() { + this.isTermsModalOpen = false; + this.joinApplicationTerms.setTermsAccepted(true); + } +} diff --git a/app/components/new-signup/input.hbs b/app/components/new-signup/input.hbs index 5e5e4e56d..eff2476ae 100644 --- a/app/components/new-signup/input.hbs +++ b/app/components/new-signup/input.hbs @@ -13,6 +13,7 @@ placeholder="Darth" aria-labelledby="signup-form-label" {{on "input" this.inputFieldChanged}} + {{on "keydown" this.handleKeydown}} data-test-signup-form-input /> {{#if @error}} diff --git a/app/components/new-signup/input.js b/app/components/new-signup/input.js index abe24686f..4e4210a51 100644 --- a/app/components/new-signup/input.js +++ b/app/components/new-signup/input.js @@ -9,8 +9,31 @@ export default class SignupComponent extends Component { return LABEL_TEXT[currentStep]; } - @action inputFieldChanged({ target: { value } }) { + @action inputFieldChanged(event) { const { onChange, currentStep } = this.args; - onChange(currentStep, value); + + const rawValue = event.target.value; + + if (/\s/.test(rawValue)) { + const cursorPosition = event.target.selectionStart; + const sanitizedInput = rawValue.replace(/\s/g, ''); + + const textBeforeCursor = rawValue.substring(0, cursorPosition); + const spacesBeforeCursor = (textBeforeCursor.match(/\s/g) || []).length; + const newCursorPosition = cursorPosition - spacesBeforeCursor; + + event.target.value = sanitizedInput; + event.target.setSelectionRange(newCursorPosition, newCursorPosition); + + onChange(currentStep, sanitizedInput); + } else { + onChange(currentStep, rawValue); + } + } + + @action handleKeydown(event) { + if (/\s/.test(event.key)) { + event.preventDefault(); + } } } diff --git a/app/components/new-stepper.hbs b/app/components/new-stepper.hbs new file mode 100644 index 000000000..f2962269b --- /dev/null +++ b/app/components/new-stepper.hbs @@ -0,0 +1,57 @@ +
    + {{#if (and (not-eq this.currentStep this.MIN_STEP) (not-eq this.currentStep 7))}} + + {{/if}} + +
    + {{#if (eq this.currentStep this.MIN_STEP)}} + + +
    + +
    + + {{else if (eq this.currentStep 1)}} + + + {{else if (eq this.currentStep 2)}} + + + {{else if (eq this.currentStep 3)}} + + + {{else if (eq this.currentStep 4)}} + + + {{else if (eq this.currentStep 5)}} + + + {{else if (eq this.currentStep 6)}} + + + {{else if (eq this.currentStep 7)}} + + {{/if}} + + + {{#if (and (not-eq this.currentStep this.MIN_STEP) (not-eq this.currentStep 7))}} +
    + {{#if this.showPreviousButton}} + + {{/if}} + + +
    + {{/if}} +
    \ No newline at end of file diff --git a/app/components/new-stepper.js b/app/components/new-stepper.js new file mode 100644 index 000000000..ff0cc434c --- /dev/null +++ b/app/components/new-stepper.js @@ -0,0 +1,115 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { NEW_FORM_STEPS } from '../constants/new-join-form'; +import { getLocalStorageItem, setLocalStorageItem } from '../utils/storage'; + +export default class NewStepperComponent extends Component { + MIN_STEP = 0; + MAX_STEP = 6; + applicationId = '4gchuf690'; + + @service login; + @service router; + @service onboarding; + @service joinApplicationTerms; + + @tracked preValid = false; + @tracked isValid = getLocalStorageItem('isValid') === 'true'; + + @tracked currentStep = 0; + + constructor() { + super(...arguments); + + const storedStep = getLocalStorageItem('currentStep'); + const stepFromArgs = this.args.step; + this.currentStep = storedStep + ? Number(storedStep) + : stepFromArgs != null + ? Number(stepFromArgs) + : 0; + } + + setIsValid = (newVal) => (this.isValid = newVal); + setIsPreValid = (newVal) => (this.preValid = newVal); + + updateQueryParam(step) { + const existingQueryParams = this.router.currentRoute?.queryParams; + this.router.transitionTo('join', { + queryParams: { + ...existingQueryParams, + step, + }, + }); + } + + get showPreviousButton() { + return this.currentStep > this.MIN_STEP + 1; + } + + get currentHeading() { + return NEW_FORM_STEPS.headings[this.currentStep - 1] ?? ''; + } + + get currentSubheading() { + return NEW_FORM_STEPS.subheadings[this.currentStep - 1] ?? ''; + } + + get firstName() { + return localStorage.getItem('first_name') ?? ''; + } + + get isNextButtonDisabled() { + return !(this.preValid || this.isValid); + } + + get isReviewStep() { + return this.currentStep === this.MAX_STEP; + } + + @action incrementStep() { + if (this.currentStep < this.MAX_STEP) { + const nextStep = this.currentStep + 1; + setLocalStorageItem('currentStep', String(nextStep)); + this.currentStep = nextStep; + this.updateQueryParam(nextStep); + } + } + + @action decrementStep() { + if (this.currentStep > this.MIN_STEP) { + const previousStep = this.currentStep - 1; + setLocalStorageItem('currentStep', String(previousStep)); + this.currentStep = previousStep; + this.updateQueryParam(previousStep); + } + } + + @action startHandler() { + sessionStorage.setItem('id', this.login.userData.id); + sessionStorage.setItem('first_name', this.login.userData.first_name); + sessionStorage.setItem('last_name', this.login.userData.last_name); + this.incrementStep(); + } + + @action navigateToStep(stepNumber) { + if (stepNumber >= this.MIN_STEP + 1 && stepNumber <= this.MAX_STEP) { + this.isValid = false; + this.preValid = false; + this.currentStep = stepNumber; + setLocalStorageItem('currentStep', String(stepNumber)); + setLocalStorageItem('isValid', 'false'); + this.updateQueryParam(stepNumber); + } + } + + @action handleSubmit() { + // ToDo: handle create application + console.log('Submit application for review'); + this.currentStep = this.MAX_STEP + 1; + setLocalStorageItem('currentStep', String(this.currentStep)); + this.updateQueryParam(this.currentStep); + } +} diff --git a/app/components/reusables/button.hbs b/app/components/reusables/button.hbs index 60c9caf1b..da4b0155e 100644 --- a/app/components/reusables/button.hbs +++ b/app/components/reusables/button.hbs @@ -2,7 +2,8 @@ data-test-button={{@test}} class="btn btn-{{@variant}} btn-{{@classGenerateUsername}} - {{if @disabled 'btn-disabled' ''}}" + {{if @disabled 'btn-disabled' ''}} + {{@class}}" type={{@type}} disabled={{@disabled}} title={{@title}} diff --git a/app/components/reusables/input-box.hbs b/app/components/reusables/input-box.hbs index 4d842bb40..30491d75d 100644 --- a/app/components/reusables/input-box.hbs +++ b/app/components/reusables/input-box.hbs @@ -13,6 +13,7 @@ id={{@name}} placeholder={{@placeHolder}} required={{@required}} + disabled={{@disabled}} value={{@value}} {{on "input" @onInput}} /> diff --git a/app/components/terms-modal.hbs b/app/components/terms-modal.hbs new file mode 100644 index 000000000..8eea675ca --- /dev/null +++ b/app/components/terms-modal.hbs @@ -0,0 +1,50 @@ + +
    +
    +

    Terms and Conditions

    + +
    + +
    +
    +

    1. Application Accuracy

    +

    + All information provided in your application must be truthful and accurate. + Any false or misleading information will result in immediate rejection of your application. +

    +
    + +
    +

    2. User Profile Picture

    +

    + Users are required to upload a professional headshot as part of their profile. The submitted image will be securely stored in our databases and used for identification and verification purposes. +

    +
    + +
    +

    3. Sharing Personal Information

    +

    + We may use trusted third-party API services to analyze and generate AI-based reviews. By submitting your information, you consent to the secure sharing of relevant data with these services for evaluation purposes. +

    +
    +
    + + {{#unless this.joinApplicationTerms.hasUserAcceptedTerms}} + + {{/unless}} +
    +
    diff --git a/app/components/terms-modal.js b/app/components/terms-modal.js new file mode 100644 index 000000000..b453e3aad --- /dev/null +++ b/app/components/terms-modal.js @@ -0,0 +1,6 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; + +export default class TermsModalComponent extends Component { + @service joinApplicationTerms; +} diff --git a/app/constants/apis.js b/app/constants/apis.js index 3c518132f..4abc2b469 100644 --- a/app/constants/apis.js +++ b/app/constants/apis.js @@ -42,3 +42,11 @@ export const SELF_USER_PROFILE_URL = `${APPS.API_BACKEND}/users?profile=true`; export const QR_AUTHORIZATION_STATUS_URL = `${APPS.API_BACKEND}/auth/qr-code-auth/authorization_status`; export const USER_AUTHENTICATED_DEVICES_URL = `${APPS.API_BACKEND}/auth/device`; + +export const APPLICATIONS_URL = (size = 6) => { + return `${APPS.API_BACKEND}/applications?size=${size}&dev=true`; +}; + +export const APPLICATION_BY_ID_URL = (applicationId) => { + return `${APPS.API_BACKEND}/applications/${applicationId}?dev=true`; +}; diff --git a/app/constants/new-join-form.js b/app/constants/new-join-form.js new file mode 100644 index 000000000..0cfb9fb63 --- /dev/null +++ b/app/constants/new-join-form.js @@ -0,0 +1,67 @@ +export const NEW_FORM_STEPS = { + headings: [ + 'Upload Professional Headshot and Complete Personal Details', + 'Additional Personal Information', + 'Your hobbies, interests, fun fact', + 'Connect your social profiles', + 'Why Real Dev Squad?', + 'Review and Submit', + ], + subheadings: [ + 'Please provide accurate information for verification purposes.', + 'Introduce and help us get to know you better', + 'Show us your funny and interesting side', + 'Share your social media and professional profiles', + 'Tell us why you want to join our community', + 'Review your answers before submitting.', + ], +}; + +export const ROLE_OPTIONS = [ + 'Developer', + 'Designer', + 'Product Manager', + 'Project Manager', + 'QA', + 'Social Media', +]; + +export const NEW_STEP_LIMITS = { + stepOne: { + country: { min: 1, type: 'dropdown' }, + state: { min: 1 }, + city: { min: 1 }, + role: { min: 1, type: 'select' }, + }, + stepTwo: { + skills: { min: 5, max: 20 }, + company: { min: 1 }, + introduction: { min: 100, max: 500 }, + }, + stepThree: { + hobbies: { min: 100, max: 500 }, + funFact: { min: 100, max: 500 }, + }, + stepFour: { + phoneNumber: { min: 1 }, + twitter: { min: 1 }, + github: { min: 1 }, + linkedin: { min: 1 }, + instagram: { min: 0 }, + peerlist: { min: 1 }, + behance: { min: 1 }, + dribble: { min: 1 }, + }, + stepFive: { + whyRds: { min: 100 }, + foundFrom: { min: 1 }, + }, +}; + +export const STEP_DATA_STORAGE_KEY = { + stepOne: 'newStepOneData', + stepTwo: 'newStepTwoData', + stepThree: 'newStepThreeData', + stepFour: 'newStepFourData', + stepFive: 'newStepFiveData', +}; diff --git a/app/controllers/applications.js b/app/controllers/applications.js new file mode 100644 index 000000000..808108efa --- /dev/null +++ b/app/controllers/applications.js @@ -0,0 +1,6 @@ +import Controller from '@ember/controller'; +import { service } from '@ember/service'; + +export default class ApplicationsController extends Controller { + @service router; +} diff --git a/app/controllers/join.js b/app/controllers/join.js index cda0a8d63..c0356d7e1 100644 --- a/app/controllers/join.js +++ b/app/controllers/join.js @@ -13,15 +13,25 @@ export default class JoinController extends Controller { @tracked chaincode = 'Generate chaincode'; @tracked isChaincodeClicked = false; @tracked isLoading = false; + @tracked oldOnboarding = null; + @tracked step = null; ANKUSH_TWITTER = ANKUSH_TWITTER; - queryParams = ['step', 'dev']; + queryParams = ['step', 'dev', 'oldOnboarding']; get isDevMode() { return this.featureFlag.isDevMode; } + get isOldOnboarding() { + return this.oldOnboarding === 'true'; + } + + get stepFromParam() { + return Number(this.step) ?? 0; + } + get applicationData() { return this.onboarding.applicationData; } diff --git a/app/router.js b/app/router.js index 2c30d7227..01cf4e0f5 100644 --- a/app/router.js +++ b/app/router.js @@ -24,4 +24,7 @@ Router.map(function () { this.route('notifications'); this.route('mobile'); this.route('new-signup'); + this.route('applications', function () { + this.route('detail', { path: '/:id' }); + }); }); diff --git a/app/routes/applications.js b/app/routes/applications.js new file mode 100644 index 000000000..cfbee186c --- /dev/null +++ b/app/routes/applications.js @@ -0,0 +1,61 @@ +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; +import { TOAST_OPTIONS } from '../constants/toast-options'; +import { APPLICATIONS_URL, SELF_USER_PROFILE_URL } from '../constants/apis'; +import redirectAuth from '../utils/redirect-auth'; +import { ERROR_MESSAGES } from '../constants/error-messages'; +import apiRequest from '../utils/api-request'; + +const APPLICATIONS_SIZE = 6; + +export default class ApplicationsRoute extends Route { + @service toast; + @service router; + + queryParams = { + dev: { refreshModel: true }, + }; + + beforeModel(transition) { + if (transition?.to?.queryParams?.dev !== 'true') { + this.router.replaceWith('/page-not-found'); + } + } + + async model(params, transition) { + if (transition?.to?.name === 'applications.detail') { + return null; + } + + try { + const userResponse = await apiRequest(SELF_USER_PROFILE_URL); + + if (userResponse.status === 401) { + this.toast.error(ERROR_MESSAGES.notLoggedIn, '', TOAST_OPTIONS); + setTimeout(redirectAuth, 2000); + return null; + } + + if (!userResponse.ok) { + throw new Error(`HTTP error! status: ${userResponse.status}`); + } + + const applicationsResponse = await apiRequest( + APPLICATIONS_URL(APPLICATIONS_SIZE), + ); + if (!applicationsResponse.ok) { + throw new Error(`HTTP error! status: ${applicationsResponse.status}`); + } + + const applicationsData = await applicationsResponse.json(); + return applicationsData.applications || []; + } catch (error) { + this.toast.error( + 'Something went wrong. ' + error.message, + 'Error!', + TOAST_OPTIONS, + ); + return null; + } + } +} diff --git a/app/routes/applications/detail.js b/app/routes/applications/detail.js new file mode 100644 index 000000000..7df24e838 --- /dev/null +++ b/app/routes/applications/detail.js @@ -0,0 +1,54 @@ +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; +import { + APPLICATION_BY_ID_URL, + SELF_USER_PROFILE_URL, +} from '../../constants/apis'; +import { ERROR_MESSAGES } from '../../constants/error-messages'; +import { TOAST_OPTIONS } from '../../constants/toast-options'; +import apiRequest from '../../utils/api-request'; +import redirectAuth from '../../utils/redirect-auth'; + +export default class ApplicationsDetailRoute extends Route { + @service toast; + @service router; + + async model(params) { + try { + const userResponse = await apiRequest(SELF_USER_PROFILE_URL); + + if (userResponse.status === 401) { + this.toast.error(ERROR_MESSAGES.notLoggedIn, '', TOAST_OPTIONS); + setTimeout(redirectAuth, 2000); + return null; + } + + if (!userResponse.ok) { + throw new Error(`HTTP error! status: ${userResponse.status}`); + } + + const applicationResponse = await apiRequest( + APPLICATION_BY_ID_URL(params.id), + ); + + if (applicationResponse.status === 404) { + this.toast.error('Application not found', 'Error!', TOAST_OPTIONS); + return null; + } + + if (!applicationResponse.ok) { + throw new Error(`HTTP error! status: ${applicationResponse.status}`); + } + + const applicationData = await applicationResponse.json(); + return applicationData?.application; + } catch (error) { + this.toast.error( + 'Something went wrong. ' + error.message, + 'Error!', + TOAST_OPTIONS, + ); + return null; + } + } +} diff --git a/app/services/join-application-terms.js b/app/services/join-application-terms.js new file mode 100644 index 000000000..16b20e8ff --- /dev/null +++ b/app/services/join-application-terms.js @@ -0,0 +1,12 @@ +import Service from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +export default class joinApplicationTermsService extends Service { + @tracked hasUserAcceptedTerms = false; + + @action + setTermsAccepted(isAccepted) { + this.hasUserAcceptedTerms = isAccepted; + } +} diff --git a/app/styles/app.css b/app/styles/app.css index 1b44bfc0a..d66c06c7c 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -57,6 +57,7 @@ @import url("qrcode.module.css"); @import url("confirm-modal.module.css"); @import url("new-signup.module.css"); +@import url("new-stepper.module.css"); * { margin: 0; diff --git a/app/styles/new-stepper.module.css b/app/styles/new-stepper.module.css new file mode 100644 index 000000000..10f1aec86 --- /dev/null +++ b/app/styles/new-stepper.module.css @@ -0,0 +1,550 @@ +.new-stepper__form { + width: 60vw; + margin: 0 auto 3rem; + padding: 2rem; + background-color: var(--color-bgpink); + border-radius: 0.5rem; + box-sizing: border-box; +} + +.new-stepper__buttons { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + width: 60vw; +} + +.prev-button { + grid-column: 1; +} + +.next-button { + grid-column: 3; + justify-self: end; +} + +.welcome-screen, +.thank-you-screen { + width: 90%; + margin: 0 auto 2rem; + display: flex; + flex-direction: column; + gap: 2rem; + justify-content: center; +} + +.welcome-screen__logo { + display: flex; + justify-content: center; +} + +.welcome-screen__info-item--bullet, +.thank-you-screen__info-item--bullet { + display: flex; + align-items: center; + margin-bottom: 1rem; + color: var(--color-darkblack); + font-size: 1rem; + line-height: 1.5; +} + +.welcome-screen__info-item--bullet::before, +.thank-you-screen__info-item--bullet::before { + content: "•"; + color: var(--color-pink); + font-size: 1.5rem; + font-weight: bold; + margin-right: 0.75rem; +} + +.welcome-screen__checkbox { + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; +} + +.welcome-screen__checkbox-text { + color: var(--color-darkblack); + font-size: 0.875rem; + line-height: 1.5; +} + +.welcome-screen__actions, +.thank-you-screen__actions { + display: flex; + justify-content: center; +} + +.terms-modal__content { + background: var(--color-white); + border-radius: 0.5rem; + padding: 2rem; + width: 90vw; + max-width: 24rem; + max-height: 80vh; + overflow-y: scroll; + display: flex; + flex-direction: column; + gap: 2rem; +} + +.terms-modal__header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.terms-modal__header h2 { + font-size: 1.5rem; +} + +.terms-modal__close.icon-button { + background-color: transparent; + margin: 0; +} + +.terms-modal__close.icon-button:hover { + background-color: var(--color-lightgrey); + transition: all 0.3s ease-in-out; +} + +.terms-modal__section { + padding-block: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.terms-modal__footer { + display: flex; + justify-content: center; +} + +.form-header { + width: 60vw; + margin-bottom: 2rem; +} + +.form-header__content { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.form-header__heading { + font-size: 1.5rem; + font-weight: 600; +} + +.form-header__subheading { + color: var(--color-lightgrey); + margin: 0; +} + +.form-header__step-count { + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.form-header__step-current { + font-size: 1.75rem; + font-weight: 700; + color: var(--color-darkblack); +} + +.form-header__step-total { + font-size: 0.875rem; + color: var(--color-black-light); +} + +.progress-bar { + width: 100%; + height: 0.5rem; + background-color: var(--color-pink-low-opacity); + border-radius: 0.25rem; + overflow: hidden; +} + +.progress-bar__fill { + height: 100%; + background: var(--color-navyblue); + width: 0%; + transition: width 0.3s ease; +} + +.two-column-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3rem; +} + +.two-column-layout__left { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.section-heading { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 0.25rem; + color: var(--color-black); +} + +.section-instruction { + font-size: 0.875rem; + color: var(--color-black-light); +} + +.image-upload-box { + border: 2px dashed var(--color-lightgrey); + border-radius: 0.5rem; + padding: 2rem; + text-align: center; + background-color: var(--color-white); +} + +.image-upload-box:hover { + border-color: var(--color-navyblue); + cursor: pointer; + transition: all 0.3s ease-in-out; +} + +.image-requirements { + font-size: 0.875rem; + color: var(--color-black-light); +} + +.image-requirements h4 { + margin: 0 0 0.5rem; + font-size: 1rem; + color: var(--color-black-light); +} + +.image-requirements ul { + margin: 0; + padding-left: 1.25rem; +} + +.image-requirements li { + margin-bottom: 0.25rem; +} + +.image-preview-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.image-preview { + width: 9.5rem; + height: 9.5rem; + object-fit: cover; + border-radius: 0.75rem; + border: 1px solid var(--color-lightgrey); + margin: 0; +} + +.image-preview-container .btn-light { + width: 9rem; + height: 2.25rem; +} + +.role-selection { + margin-top: 1rem; +} + +.role-selection p { + margin: 0 0 0.5rem; + font-size: 1rem; + font-weight: 500; + color: var(--color-black); +} + +.role-buttons { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.role-button { + padding: 0.5rem 1rem; + border: 2px solid var(--profile-field-input-border-clr); + background-color: var(--color-white); + border-radius: 0.375rem; + cursor: pointer; + font-size: 0.875rem; +} + +.role-button:hover { + border-color: var(--color-navyblue); + background-color: var(--color-offwhite); + transition: all 0.3s ease; +} + +.role-button--selected { + border-color: var(--color-navyblue); + background-color: var(--color-navyblue); + color: var(--color-white); +} + +.role-button--selected:hover { + background-color: var(--color-navyblue); +} + +.step-container { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.form-grid { + display: grid; + gap: 1rem; + align-items: center; +} + +.form-grid--2 { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); +} + +.form-grid__item { + display: flex; + flex-direction: column; + min-width: 0; +} + +.word-count { + font-size: 0.75rem; + color: var(--color-black-light); + text-align: right; + margin-top: 0.25rem; +} + +.step-container input, +.step-container textarea, +.step-container select { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +.step-container .textarea-box { + margin: 0; +} + +.review-section { + background-color: var(--color-bgpink); + border-radius: 0.5rem; + padding: 1.5rem; + margin-bottom: 1.5rem; + box-shadow: 0 0.125rem 0.25rem var(--color-blackshadow2); +} + +.review-section__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--color-lightgrey); +} + +.review-section__header .btn--sm { + height: 2rem; + width: 5rem; +} + +.review-section__title { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-black); + margin: 0; +} + +.review-section__content { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.review-field { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 0.5rem 0; + gap: 1rem; +} + +.review-field__label { + font-weight: 500; + color: var(--color-black); + flex-shrink: 0; + min-width: 12.5rem; +} + +.review-field__value { + color: var(--color-black); + word-wrap: break-word; + flex: 1; + max-width: 60%; +} + +.review-field__value--missing { + color: var(--text-red); +} + +.review-field__value--empty { + color: var(--color-lightgrey); +} + +.review-actions { + display: flex; + justify-content: center; + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--color-lightgrey); +} + +.thank-you-screen { + align-items: center; + text-align: center; + margin: 2rem auto 0; +} + +.thank-you-screen__info-container { + text-align: start; + margin-block: 1.5rem; +} + +.thank-you-screen__logo { + display: flex; + justify-content: center; + align-items: center; + width: 3rem; + height: 3em; + border-radius: 50%; + background-color: var(--color-navyblue); + color: var(--color-white); +} + +.application-id h3 { + font-size: 1rem; + font-weight: 500; +} + +.application-id p { + font-size: 1.25rem; + font-weight: 700; +} + +@media screen and (width <=1280px) { + .new-stepper__form, + .form-header, + .new-stepper__buttons { + width: 75vw; + } +} + +@media screen and (width <=1024px) { + .new-stepper__form, + .form-header, + .new-stepper__buttons { + width: 80vw; + } +} + +@media screen and (width <=768px) { + .two-column-layout { + grid-template-columns: 1fr; + } + + .form-header, + .new-stepper__form, + .new-stepper__buttons { + width: 90vw; + } + + .new-stepper__form { + padding: 1rem; + } + + .form-grid--2 { + grid-template-columns: 1fr; + } + + .welcome-screen, + .thank-you-screen { + margin: 1rem auto; + padding: 1.5rem; + } + + .welcome-screen img { + width: 6.25rem; + } + + .terms-modal__content { + width: 95vw; + margin: 1rem; + } + + .review-section { + padding: 1rem; + } + + .review-section__header { + align-items: center; + gap: 0.75rem; + } + + .review-field { + flex-direction: column; + gap: 0.25rem; + } + + .review-field__label { + min-width: auto; + } + + .review-field__value { + text-align: left; + max-width: 100%; + } + + .form-header__text { + line-height: 1.5; + } +} + +@media screen and (width <=480px) { + .form-header, + .new-stepper__form, + .new-stepper__buttons { + width: 95%; + } + + .new-stepper__form { + padding: 1rem 0.5rem; + } + + .terms-modal__content { + padding: 1rem; + } + + .welcome-screen, + .thank-you-screen { + margin: 0.5rem auto 1rem; + padding: 1rem; + } + + .welcome-screen img { + width: 5rem; + } + + .welcome-screen__checkbox-text { + font-size: 0.875rem; + } +} diff --git a/app/templates/applications.hbs b/app/templates/applications.hbs new file mode 100644 index 000000000..9a0641d66 --- /dev/null +++ b/app/templates/applications.hbs @@ -0,0 +1,20 @@ +
    + {{#unless (eq this.router.currentRouteName "applications.detail")}} +

    Applications

    + {{#if (and this.model (gt this.model.length 0))}} + + {{else}} +

    No applications found.

    + {{/if}} + {{/unless}} + {{outlet}} +
    \ No newline at end of file diff --git a/app/templates/applications/detail.hbs b/app/templates/applications/detail.hbs new file mode 100644 index 000000000..75a1f290f --- /dev/null +++ b/app/templates/applications/detail.hbs @@ -0,0 +1,18 @@ +
    +

    Application Details

    + {{#if this.model}} +
    +
    +

    Basic Information

    +

    Application ID: {{this.model.id}}

    +

    User ID: {{this.model.userId}}

    +
    +
    + {{else}} +
    +

    Application not found.

    +

    The application you're looking for doesn't + exist or may have been removed.

    +
    + {{/if}} +
    \ No newline at end of file diff --git a/app/templates/join.hbs b/app/templates/join.hbs index 670c6cb63..161a85fe2 100644 --- a/app/templates/join.hbs +++ b/app/templates/join.hbs @@ -39,6 +39,8 @@ /> {{else}} {{#if this.isDevMode}} + + {{else if this.isOldOnboarding}} { remainingWords: remainingWords, }; }; + +export const validateWordCount = (text, wordLimits) => { + const trimmedText = text?.trim(); + const wordCount = trimmedText?.split(/\s+/).filter(Boolean).length ?? 0; + + const { min, max } = wordLimits; + if (!trimmedText) { + return { + isValid: min === 0, + wordCount: 0, + remainingToMin: min > 0 ? min : 0, + }; + } + + if (wordCount < min) { + return { isValid: false, wordCount, remainingToMin: min - wordCount }; + } else if (max && wordCount > max) { + return { isValid: false, wordCount, overByMax: wordCount - max }; + } + return { isValid: true, wordCount }; +}; diff --git a/tests/integration/components/header-test.js b/tests/integration/components/header-test.js index dc1175e21..f5dec1186 100644 --- a/tests/integration/components/header-test.js +++ b/tests/integration/components/header-test.js @@ -223,4 +223,65 @@ module('Integration | Component | header', function (hooks) { assert.dom('[data-test-dropdown-toggle]').doesNotExist(); assert.dom('[data-test-login]').doesNotExist(); }); + + test('applications link is visible for super user under dev flag', async function (assert) { + this.owner.register( + 'service:login', + { + userData: { roles: { super_user: true } }, + }, + { instantiate: false }, + ); + + this.setProperties({ + firstName: 'John', + isLoggedIn: true, + isLoading: false, + dev: true, + }); + + this.set('signOut', () => { + this.isLoggedIn = false; + }); + + await render(hbs` +
    + `); + + assert.dom('[data-test-applications]').exists(); + assert + .dom('[data-test-applications]') + .hasAttribute('href', '/applications?dev=true'); + }); + + test('applications link is not visible for normal user under dev flag', async function (assert) { + this.setProperties({ + firstName: 'John', + isLoggedIn: true, + isLoading: false, + dev: true, + }); + + this.set('signOut', () => { + this.isLoggedIn = false; + }); + + await render(hbs` +
    + `); + + assert.dom('[data-test-applications]').doesNotExist(); + }); }); diff --git a/tests/integration/components/new-join-steps/welcome-screen-test.js b/tests/integration/components/new-join-steps/welcome-screen-test.js new file mode 100644 index 000000000..6c25cf6b3 --- /dev/null +++ b/tests/integration/components/new-join-steps/welcome-screen-test.js @@ -0,0 +1,59 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'website-www/tests/helpers'; +import { render, click } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | welcome-screen', function (hooks) { + setupRenderingTest(hooks); + + test('it renders with unchecked checkbox by default', async function (assert) { + await render(hbs``); + + assert.dom('[data-test="welcome-logo"]').exists('Logo is displayed'); + assert + .dom('[data-test="welcome-greeting"]') + .hasText('Ready to apply to Real Dev Squad?'); + assert.dom('[data-test="terms-checkbox"]').exists('Checkbox exists'); + assert + .dom('[data-test="terms-checkbox"]') + .isNotChecked('Checkbox is initially unchecked'); + }); + + test('clicking unchecked checkbox opens terms modal', async function (assert) { + await render(hbs``); + assert + .dom('[data-test="terms-modal-content"]') + .doesNotExist('Terms modal is not shown initially'); + + await click('[data-test="terms-checkbox"]'); + assert + .dom('[data-test="terms-modal-content"]') + .exists('Terms modal is shown after clicking checkbox'); + }); + + test('accepting terms in modal sets checkbox to checked', async function (assert) { + await render(hbs``); + await click('[data-test="terms-checkbox"]'); + await click('[data-test-button="accept-terms"]'); + + assert + .dom('[data-test="terms-modal-content"]') + .doesNotExist('Terms modal is closed'); + assert + .dom('[data-test="terms-checkbox"]') + .isChecked('Checkbox is checked after accepting terms'); + }); + + test('can uncheck checkbox after terms have been accepted', async function (assert) { + const terms = this.owner.lookup('service:joinApplicationTerms'); + terms.hasUserAcceptedTerms = true; + + await render(hbs``); + await click('[data-test="terms-checkbox"]'); + + assert + .dom('[data-test="terms-checkbox"]') + .isNotChecked('Checkbox is unchecked after click'); + assert.false(terms.hasUserAcceptedTerms, 'Terms is set to false'); + }); +}); diff --git a/tests/integration/components/new-signup/input-test.js b/tests/integration/components/new-signup/input-test.js index e993372be..d9212b88d 100644 --- a/tests/integration/components/new-signup/input-test.js +++ b/tests/integration/components/new-signup/input-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'website-www/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, triggerEvent, typeIn } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { NEW_SIGNUP_STEPS } from 'website-www/constants/new-signup'; @@ -136,4 +136,183 @@ module('Integration | Component | new-signup/input', function (hooks) { assert.dom('[data-test-button="signup"]').isDisabled(); }); + + module('whitespace handling', function (hooks) { + hooks.beforeEach(function () { + this.setProperties({ + currentStep: 'firstName', + onChange: (step, value) => { + this.inputValue = value; + }, + onClick: () => {}, + }); + }); + + test('should prevent single space when typing', async function (assert) { + await render(hbs` + + `); + + const input = this.element.querySelector('[data-test-signup-form-input]'); + + await typeIn(input, 'John Doe'); + + assert + .dom(input) + .hasValue('JohnDoe', 'Single space should be prevented when typing'); + }); + + test('should remove multiple consecutive spaces when typing', async function (assert) { + await render(hbs` + + `); + + const input = this.element.querySelector('[data-test-signup-form-input]'); + + await typeIn(input, 'John Doe'); + + assert + .dom(input) + .hasValue('JohnDoe', 'Multiple consecutive spaces should be removed'); + }); + + test('should remove single space when pasting', async function (assert) { + await render(hbs` + + `); + + const input = this.element.querySelector('[data-test-signup-form-input]'); + input.value = 'John Doe'; + await triggerEvent(input, 'input'); + + assert.strictEqual( + this.inputValue, + 'JohnDoe', + 'Single space should be removed when pasting', + ); + assert.dom(input).hasValue('JohnDoe'); + }); + + test('should remove leading and trailing spaces when pasting', async function (assert) { + await render(hbs` + + `); + + const input = this.element.querySelector('[data-test-signup-form-input]'); + input.value = ' John Doe '; + await triggerEvent(input, 'input'); + + assert.strictEqual( + this.inputValue, + 'JohnDoe', + 'Leading and trailing spaces should be removed', + ); + assert.dom(input).hasValue('JohnDoe'); + }); + + test('should handle input with only spaces', async function (assert) { + await render(hbs` + + `); + + const input = this.element.querySelector('[data-test-signup-form-input]'); + input.value = ' '; + await triggerEvent(input, 'input'); + + assert.strictEqual( + this.inputValue, + '', + 'Input with only spaces should result in empty string', + ); + assert.dom(input).hasValue(''); + }); + + test('should remove mixed whitespace characters when pasting', async function (assert) { + await render(hbs` + + `); + + const input = this.element.querySelector('[data-test-signup-form-input]'); + input.value = 'John\t\nDoe'; + await triggerEvent(input, 'input'); + + assert.strictEqual( + this.inputValue, + 'JohnDoe', + 'Tabs and newlines should be removed', + ); + assert.dom(input).hasValue('JohnDoe'); + }); + + test('should accept text without whitespace', async function (assert) { + await render(hbs` + + `); + + const input = this.element.querySelector('[data-test-signup-form-input]'); + + await typeIn(input, 'JohnDoe'); + + assert.strictEqual( + this.inputValue, + 'JohnDoe', + 'Text without whitespace should be accepted as-is', + ); + assert.dom(input).hasValue('JohnDoe'); + }); + + test('should handle combination of typing and pasting with whitespace', async function (assert) { + await render(hbs` + + `); + + const input = this.element.querySelector('[data-test-signup-form-input]'); + + // First type some text + await typeIn(input, 'John '); + + // Then paste text with spaces + input.value = input.value + ' Doe Smith'; + await triggerEvent(input, 'input'); + + assert.strictEqual( + this.inputValue, + 'JohnDoeSmith', + 'Combination of typing and pasting should remove all spaces', + ); + assert.dom(input).hasValue('JohnDoeSmith'); + }); + }); }); diff --git a/tests/integration/components/new-stepper-test.js b/tests/integration/components/new-stepper-test.js new file mode 100644 index 000000000..bce8ee5c0 --- /dev/null +++ b/tests/integration/components/new-stepper-test.js @@ -0,0 +1,38 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'website-www/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | new-stepper', function (hooks) { + setupRenderingTest(hooks); + + test('it renders the welcome screen at step 0', async function (assert) { + await render(hbs``); + + assert.dom('[data-test="stepper"]').exists('Stepper component is rendered'); + assert + .dom('[data-test="welcome-screen"]') + .exists('Welcome screen is rendered'); + assert + .dom('[data-test="welcome-greeting"]') + .hasText('Ready to apply to Real Dev Squad?'); + assert.dom('[data-test-button="start"]').exists('Start button exists'); + }); + + test('start button is disabled when terms are not accepted', async function (assert) { + await render(hbs``); + assert + .dom('[data-test-button="start"]') + .isDisabled('Start button is disabled by default'); + }); + + test('start button is enabled when terms are accepted', async function (assert) { + const terms = this.owner.lookup('service:joinApplicationTerms'); + terms.hasUserAcceptedTerms = true; + + await render(hbs``); + assert + .dom('[data-test-button="start"]') + .isNotDisabled('Start button is enabled when terms are accepted'); + }); +}); diff --git a/tests/integration/components/terms-modal-test.js b/tests/integration/components/terms-modal-test.js new file mode 100644 index 000000000..2e482dd53 --- /dev/null +++ b/tests/integration/components/terms-modal-test.js @@ -0,0 +1,70 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'website-www/tests/helpers'; +import { render, click } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | terms-modal', function (hooks) { + setupRenderingTest(hooks); + + test('it renders modal content when @isOpen is true', async function (assert) { + this.setProperties({ + isOpen: true, + onAccept: () => {}, + onClose: () => {}, + }); + await render(hbs` + + `); + assert + .dom('[data-test="terms-modal-content"]') + .exists('Modal content is displayed'); + }); + + test('it shows Accept Terms button and correctly calls onAccept action', async function (assert) { + assert.expect(3); + const terms = this.owner.lookup('service:joinApplicationTerms'); + terms.hasUserAcceptedTerms = false; + this.setProperties({ + isOpen: true, + onAccept: () => assert.step('accept-called'), + onClose: () => {}, + }); + await render(hbs` + + `); + assert + .dom('[data-test-button="accept-terms"]') + .exists('Accept Terms button is displayed'); + + await click('[data-test-button="accept-terms"]'); + assert.verifySteps(['accept-called'], 'onAccept action was called'); + }); + + test('it does not show Accept Terms button when terms are already accepted', async function (assert) { + const terms = this.owner.lookup('service:joinApplicationTerms'); + terms.hasUserAcceptedTerms = true; + this.setProperties({ + isOpen: true, + onAccept: () => {}, + onClose: () => {}, + }); + await render(hbs` + + `); + assert + .dom('[data-test-button="accept-terms"]') + .doesNotExist('Accept Terms button is not displayed'); + }); +}); diff --git a/tests/unit/controllers/applications-test.js b/tests/unit/controllers/applications-test.js new file mode 100644 index 000000000..01c6a48a2 --- /dev/null +++ b/tests/unit/controllers/applications-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'website-www/tests/helpers'; + +module('Unit | Controller | applications', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + assert.expect(1); + const controller = this.owner.lookup('controller:applications'); + assert.ok(controller, 'The applications controller exists'); + }); +}); diff --git a/tests/unit/controllers/join-test.js b/tests/unit/controllers/join-test.js index 83a33f319..60a612c45 100644 --- a/tests/unit/controllers/join-test.js +++ b/tests/unit/controllers/join-test.js @@ -21,7 +21,21 @@ module('Unit | Controller | join', function (hooks) { test('it has queryParams', function (assert) { let controller = this.owner.lookup('controller:join'); - assert.deepEqual(controller.queryParams, ['step', 'dev']); + assert.deepEqual(controller.queryParams, ['step', 'dev', 'oldOnboarding']); + }); + + test('isOldOnboarding returns values correctly as per oldOnboarding query param', function (assert) { + let controller = this.owner.lookup('controller:join'); + + controller.oldOnboarding = 'true'; + assert.true(controller.isOldOnboarding, 'isOldOnboarding is true'); + controller.oldOnboarding = 'false'; + assert.false(controller.isOldOnboarding, 'isOldOnboarding is false'); + controller.oldOnboarding = null; + assert.false( + controller.isOldOnboarding, + 'isOldOnboarding is false for null', + ); }); test('it correctly identifies when a user is logged in', function (assert) { diff --git a/tests/unit/routes/applications-test.js b/tests/unit/routes/applications-test.js new file mode 100644 index 000000000..fd36e4563 --- /dev/null +++ b/tests/unit/routes/applications-test.js @@ -0,0 +1,125 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'website-www/tests/helpers'; +import sinon from 'sinon'; +import { + APPLICATIONS_URL, + SELF_USER_PROFILE_URL, +} from 'website-www/constants/apis'; + +const APPLICATIONS_SIZE = 6; + +module('Unit | Route | applications', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.fetchStub = sinon.stub(window, 'fetch'); + this.route = this.owner.lookup('route:applications'); + sinon.stub(this.route.router, 'replaceWith'); + sinon.stub(this.route.toast, 'error'); + }); + + hooks.afterEach(function () { + this.fetchStub.restore(); + sinon.restore(); + }); + + test('it exists', function (assert) { + assert.expect(1); + const route = this.owner.lookup('route:applications'); + assert.ok(route, 'The applications route exists'); + }); + + test('redirects to page-not-found when dev flag is not true', function (assert) { + const transition = { to: { queryParams: { dev: 'false' } } }; + + this.route.beforeModel(transition); + + assert.ok( + this.route.router.replaceWith.calledOnceWith('/page-not-found'), + 'Redirected to /page-not-found when dev is not true', + ); + }); + + test('allows access when dev flag is true', function (assert) { + const transition = { to: { queryParams: { dev: 'true' } } }; + + this.route.beforeModel(transition); + + assert.ok( + this.route.router.replaceWith.notCalled, + 'No redirection occurs when dev query param is true', + ); + }); + + test('returns null when transitioning to applications.detail', async function (assert) { + const transition = { to: { name: 'applications.detail' } }; + + const result = await this.route.model({}, transition); + + assert.strictEqual(result, null, 'Returns null for detail route'); + assert.ok(this.fetchStub.notCalled, 'No API calls made for detail route'); + }); + + test('fetches applications successfully', async function (assert) { + const mockApplications = [ + { id: 1, userId: 'user1' }, + { id: 2, userId: 'user2' }, + ]; + + this.fetchStub + .onCall(0) + .resolves( + new Response(JSON.stringify({ first_name: 'John' }), { status: 200 }), + ); + + this.fetchStub.onCall(1).resolves( + new Response(JSON.stringify({ applications: mockApplications }), { + status: 200, + }), + ); + + const transition = { to: { name: 'applications' } }; + const result = await this.route.model({}, transition); + + assert.deepEqual(result, mockApplications, 'Returns applications from API'); + assert.ok( + this.fetchStub.firstCall.calledWith( + SELF_USER_PROFILE_URL, + sinon.match.object, + ), + 'First API call is made to check user profile', + ); + assert.ok( + this.fetchStub.secondCall.calledWith( + APPLICATIONS_URL(APPLICATIONS_SIZE), + sinon.match.object, + ), + 'Second API call is made to fetch applications', + ); + }); + + test('displays error toast and redirects on 401 response', async function (assert) { + this.fetchStub.resolves(new Response(JSON.stringify({}), { status: 401 })); + + const transition = { to: { name: 'applications' } }; + const result = await this.route.model({}, transition); + + assert.strictEqual(result, null, 'Returns null for 401'); + assert.ok(this.route.toast.error.calledOnce, 'Error toast is displayed'); + }); + + test('displays error toast on API error', async function (assert) { + this.fetchStub + .onCall(0) + .resolves(new Response(JSON.stringify({}), { status: 200 })); + this.fetchStub + .onCall(1) + .resolves(new Response(JSON.stringify({}), { status: 500 })); + + const transition = { to: { name: 'applications' } }; + const result = await this.route.model({}, transition); + + assert.strictEqual(result, null, 'Returns null on error'); + assert.ok(this.route.toast.error.calledOnce, 'Error toast is displayed'); + }); +}); diff --git a/tests/unit/routes/applications/detail-test.js b/tests/unit/routes/applications/detail-test.js new file mode 100644 index 000000000..6175738cb --- /dev/null +++ b/tests/unit/routes/applications/detail-test.js @@ -0,0 +1,103 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'website-www/tests/helpers'; +import sinon from 'sinon'; +import { + APPLICATION_BY_ID_URL, + SELF_USER_PROFILE_URL, +} from 'website-www/constants/apis'; + +module('Unit | Route | applications/detail', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.fetchStub = sinon.stub(window, 'fetch'); + this.route = this.owner.lookup('route:applications/detail'); + sinon.stub(this.route.toast, 'error'); + }); + + hooks.afterEach(function () { + this.fetchStub.restore(); + sinon.restore(); + }); + + test('it exists', function (assert) { + assert.expect(1); + const route = this.owner.lookup('route:applications/detail'); + assert.ok(route, 'The applications/detail route exists'); + }); + + test('fetches application by id successfully', async function (assert) { + const mockApplication = { id: '123', userId: 'user1' }; + const applicationId = '123'; + + this.fetchStub + .onCall(0) + .resolves( + new Response(JSON.stringify({ first_name: 'John' }), { status: 200 }), + ); + + this.fetchStub.onCall(1).resolves( + new Response(JSON.stringify({ application: mockApplication }), { + status: 200, + }), + ); + + const result = await this.route.model({ id: applicationId }); + + assert.deepEqual(result, mockApplication, 'Returns application from API'); + assert.ok( + this.fetchStub.firstCall.calledWith( + SELF_USER_PROFILE_URL, + sinon.match.object, + ), + 'First API call is made to check user profile', + ); + assert.ok( + this.fetchStub.secondCall.calledWith( + APPLICATION_BY_ID_URL(applicationId), + sinon.match.object, + ), + 'Second API call is made to fetch application by id', + ); + }); + + test('displays error toast and redirects on 401 response', async function (assert) { + this.fetchStub.resolves(new Response(JSON.stringify({}), { status: 401 })); + + const result = await this.route.model({ id: '123' }); + + assert.strictEqual(result, null, 'Returns null for 401'); + assert.ok(this.route.toast.error.calledOnce, 'Error toast is displayed'); + }); + + test('displays error toast on 404 response', async function (assert) { + this.fetchStub + .onCall(0) + .resolves(new Response(JSON.stringify({}), { status: 200 })); + this.fetchStub + .onCall(1) + .resolves(new Response(JSON.stringify({}), { status: 404 })); + + const result = await this.route.model({ id: '123' }); + + assert.strictEqual(result, null, 'Returns null for 404'); + assert.ok( + this.route.toast.error.calledOnce, + 'Error toast is displayed for 404', + ); + }); + + test('displays error toast on API error', async function (assert) { + this.fetchStub + .onCall(0) + .resolves(new Response(JSON.stringify({}), { status: 200 })); + this.fetchStub + .onCall(1) + .resolves(new Response(JSON.stringify({}), { status: 500 })); + + const result = await this.route.model({ id: '123' }); + + assert.strictEqual(result, null, 'Returns null on error'); + assert.ok(this.route.toast.error.calledOnce, 'Error toast is displayed'); + }); +});