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..8818a3450 --- /dev/null +++ b/app/components/new-join-steps/base-step.js @@ -0,0 +1,125 @@ +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.onValidityChange(valid); + } + + @action inputHandler(e) { + if (!e?.target) return; + 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.onValidityChange(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-stepper.hbs b/app/components/new-stepper.hbs index 598cf5475..fb5f8a1eb 100644 --- a/app/components/new-stepper.hbs +++ b/app/components/new-stepper.hbs @@ -1,18 +1,70 @@
-
- {{#if (eq this.currentStep 0)}} - - -
- + {{/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 index 1cb753e2e..3f39b7abe 100644 --- a/app/components/new-stepper.js +++ b/app/components/new-stepper.js @@ -2,22 +2,84 @@ 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, + NEW_STEP_LIMITS, + STEP_DATA_STORAGE_KEY, +} from '../constants/new-join-form'; +import { getLocalStorageItem, setLocalStorageItem } from '../utils/storage'; +import { scheduleOnce } from '@ember/runloop'; +import { validateWordCount } from '../utils/validator'; +import { phoneNumberRegex } from '../constants/regex'; + +const NEW_STEP_CONFIG = { + 1: { + storageKey: STEP_DATA_STORAGE_KEY.stepOne, + limits: NEW_STEP_LIMITS.stepOne, + }, + 2: { + storageKey: STEP_DATA_STORAGE_KEY.stepTwo, + limits: NEW_STEP_LIMITS.stepTwo, + }, + 3: { + storageKey: STEP_DATA_STORAGE_KEY.stepThree, + limits: NEW_STEP_LIMITS.stepThree, + }, + 4: { + storageKey: STEP_DATA_STORAGE_KEY.stepFour, + limits: NEW_STEP_LIMITS.stepFour, + }, + 5: { + storageKey: STEP_DATA_STORAGE_KEY.stepFive, + limits: NEW_STEP_LIMITS.stepFive, + }, +}; export default class NewStepperComponent extends Component { MIN_STEP = 0; MAX_STEP = 6; + applicationId = '4gchuf690'; @service login; @service router; @service onboarding; @service joinApplicationTerms; - @tracked currentStep = - Number(localStorage.getItem('currentStep') ?? this.args.step) || 0; + @tracked currentStep = 0; + @tracked canAccessStep = false; + @tracked canProceedFromStep = false; + + constructor(...args) { + super(...args); + scheduleOnce('afterRender', this, this.initializeFromQuery); + } + + initializeFromQuery() { + const storedStep = getLocalStorageItem('currentStep'); + const stepFromArgs = this.args.step; + this.currentStep = storedStep + ? Number(storedStep) + : stepFromArgs != null + ? Number(stepFromArgs) + : 0; + const targetStep = this.currentStep; + const accessibleStep = this.resolveAccessibleStep(targetStep); + this.currentStep = accessibleStep; + this.canProceedFromStep = this.isStepComplete(accessibleStep); + this.persistStep(accessibleStep); + this.updateQueryParam(accessibleStep); + } + + clampStep(step) { + return Math.max(this.MIN_STEP, Math.min(this.MAX_STEP + 1, step)); + } + + persistStep(step) { + setLocalStorageItem('currentStep', String(step)); + } updateQueryParam(step) { const existingQueryParams = this.router.currentRoute?.queryParams; - this.router.transitionTo('join', { queryParams: { ...existingQueryParams, @@ -26,18 +88,89 @@ export default class NewStepperComponent extends Component { }); } + 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 sessionStorage.getItem('first_name') ?? ''; + } + + get isReviewStep() { + return this.currentStep === this.MAX_STEP; + } + + resolveAccessibleStep(stepNumber) { + const desiredStep = this.clampStep(stepNumber); + for (let step = 1; step < desiredStep; step++) { + if (!this.isStepComplete(step)) { + this.canAccessStep = false; + return step; + } + } + + this.canAccessStep = true; + return desiredStep; + } + + isStepComplete(stepNumber) { + const config = NEW_STEP_CONFIG[stepNumber]; + if (!config) { + return true; + } + + const stored = JSON.parse(getLocalStorageItem(config.storageKey, '{}')); + + for (const [field, limits] of Object.entries(config.limits || {})) { + const value = stored?.[field] ?? ''; + if (field === 'phoneNumber') { + return phoneNumberRegex.test(value); + } + const result = validateWordCount(value, limits); + if (!result.isValid) { + return false; + } + } + return true; + } + + @action onCurrentStepValidityChange(isValid) { + this.canProceedFromStep = Boolean(isValid); + if (isValid) { + this.persistStep(this.currentStep); + } + } + @action incrementStep() { - if (this.currentStep < this.MAX_STEP) { - const nextStep = this.currentStep + 1; - localStorage.setItem('currentStep', String(nextStep)); + const current = this.currentStep; + if (current < this.MAX_STEP && this.isStepComplete(current)) { + const nextStep = current + 1; + this.canAccessStep = true; + this.canProceedFromStep = this.isStepComplete(nextStep); + this.currentStep = nextStep; + this.persistStep(nextStep); this.updateQueryParam(nextStep); + } else { + this.canProceedFromStep = false; } } @action decrementStep() { - if (this.currentStep > this.MIN_STEP) { - const previousStep = this.currentStep - 1; - localStorage.setItem('currentStep', String(previousStep)); + const current = this.currentStep; + if (current > this.MIN_STEP) { + const previousStep = current - 1; + this.canAccessStep = true; + this.canProceedFromStep = this.isStepComplete(previousStep); + this.currentStep = previousStep; + this.persistStep(previousStep); this.updateQueryParam(previousStep); } } @@ -48,4 +181,23 @@ export default class NewStepperComponent extends Component { sessionStorage.setItem('last_name', this.login.userData.last_name); this.incrementStep(); } + + @action navigateToStep(stepNumber) { + const desired = this.resolveAccessibleStep(stepNumber); + this.canProceedFromStep = this.isStepComplete(desired); + this.currentStep = desired; + this.persistStep(desired); + this.updateQueryParam(desired); + } + + @action handleSubmit() { + // ToDo: handle create application and move thank you screen away from new stepper + console.log('Submit application for review'); + const completionStep = this.MAX_STEP + 1; + this.currentStep = completionStep; + this.persistStep(completionStep); + this.canAccessStep = true; + this.canProceedFromStep = false; + this.updateQueryParam(completionStep); + } } 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/constants/new-join-form.js b/app/constants/new-join-form.js new file mode 100644 index 000000000..ebd19a803 --- /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, type: 'dropdown' }, + }, +}; + +export const STEP_DATA_STORAGE_KEY = { + stepOne: 'newStepOneData', + stepTwo: 'newStepTwoData', + stepThree: 'newStepThreeData', + stepFour: 'newStepFourData', + stepFive: 'newStepFiveData', +}; diff --git a/app/styles/new-stepper.module.css b/app/styles/new-stepper.module.css index bbef5d22e..10f1aec86 100644 --- a/app/styles/new-stepper.module.css +++ b/app/styles/new-stepper.module.css @@ -8,15 +8,23 @@ } .new-stepper__buttons { - width: 60vw; - display: flex; - justify-content: space-between; + display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; - gap: 1rem; - margin: 0; + width: 60vw; +} + +.prev-button { + grid-column: 1; } -.welcome-screen { +.next-button { + grid-column: 3; + justify-self: end; +} + +.welcome-screen, +.thank-you-screen { width: 90%; margin: 0 auto 2rem; display: flex; @@ -30,7 +38,8 @@ justify-content: center; } -.welcome-screen__info-item--bullet { +.welcome-screen__info-item--bullet, +.thank-you-screen__info-item--bullet { display: flex; align-items: center; margin-bottom: 1rem; @@ -39,7 +48,8 @@ line-height: 1.5; } -.welcome-screen__info-item--bullet::before { +.welcome-screen__info-item--bullet::before, +.thank-you-screen__info-item--bullet::before { content: "•"; color: var(--color-pink); font-size: 1.5rem; @@ -60,7 +70,8 @@ line-height: 1.5; } -.welcome-screen__actions { +.welcome-screen__actions, +.thank-you-screen__actions { display: flex; justify-content: center; } @@ -398,8 +409,39 @@ border-top: 1px solid var(--color-lightgrey); } -/* MEDIA QUERIES */ -@media screen and (width <= 1280px) { +.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 { @@ -407,7 +449,7 @@ } } -@media screen and (width <= 1024px) { +@media screen and (width <=1024px) { .new-stepper__form, .form-header, .new-stepper__buttons { @@ -415,7 +457,7 @@ } } -@media screen and (width <= 768px) { +@media screen and (width <=768px) { .two-column-layout { grid-template-columns: 1fr; } @@ -434,7 +476,8 @@ grid-template-columns: 1fr; } - .welcome-screen { + .welcome-screen, + .thank-you-screen { margin: 1rem auto; padding: 1.5rem; } @@ -470,9 +513,13 @@ text-align: left; max-width: 100%; } + + .form-header__text { + line-height: 1.5; + } } -@media screen and (width <= 480px) { +@media screen and (width <=480px) { .form-header, .new-stepper__form, .new-stepper__buttons { @@ -487,7 +534,8 @@ padding: 1rem; } - .welcome-screen { + .welcome-screen, + .thank-you-screen { margin: 0.5rem auto 1rem; padding: 1rem; } diff --git a/app/utils/storage.js b/app/utils/storage.js new file mode 100644 index 000000000..31a65c78a --- /dev/null +++ b/app/utils/storage.js @@ -0,0 +1,18 @@ +export function getLocalStorageItem(key, defaultValue = null) { + try { + return localStorage.getItem(key) ?? defaultValue; + } catch (error) { + console.warn(`Failed to get localStorage item "${key}":`, error); + return defaultValue; + } +} + +export function setLocalStorageItem(key, value) { + try { + localStorage.setItem(key, value); + return true; + } catch (error) { + console.warn(`Failed to set localStorage item "${key}":`, error); + return false; + } +} diff --git a/app/utils/validator.js b/app/utils/validator.js index ec26bdea5..c9bdcd9cc 100644 --- a/app/utils/validator.js +++ b/app/utils/validator.js @@ -13,3 +13,24 @@ export const validator = (value, words) => { 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/new-stepper-test.js b/tests/integration/components/new-stepper-test.js index bce8ee5c0..dc84904d9 100644 --- a/tests/integration/components/new-stepper-test.js +++ b/tests/integration/components/new-stepper-test.js @@ -2,10 +2,20 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'website-www/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; module('Integration | Component | new-stepper', function (hooks) { setupRenderingTest(hooks); + hooks.beforeEach(function () { + class RouterStub extends Service { + currentRoute = { queryParams: {} }; + transitionTo() {} + } + + this.owner.register('service:router', RouterStub); + }); + test('it renders the welcome screen at step 0', async function (assert) { await render(hbs``); diff --git a/tests/unit/components/new-join-steps/base-step-test.js b/tests/unit/components/new-join-steps/base-step-test.js new file mode 100644 index 000000000..139efb363 --- /dev/null +++ b/tests/unit/components/new-join-steps/base-step-test.js @@ -0,0 +1,120 @@ +import { setOwner } from '@ember/application'; +import { module, test } from 'qunit'; +import BaseStepComponent from 'website-www/components/new-join-steps/base-step'; +import { setupTest } from 'website-www/tests/helpers'; + +module('Unit | Component | new-join-steps/base-step', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + const TestComponent = class extends BaseStepComponent { + constructor(owner, args) { + super(owner, args); + } + }; + this.component = Object.create(TestComponent.prototype); + setOwner(this.component, this.owner); + this.component.args = { onValidityChange: () => {} }; + this.component.data = {}; + this.component.errorMessage = {}; + this.component.wordCount = {}; + this.component.stepValidation = {}; + }); + + test('validateField returns correct result for valid input', function (assert) { + this.component.stepValidation = { field1: { min: 1, max: 10 } }; + const result = this.component.validateField('field1', 'valid input'); + + assert.true(result.isValid); + assert.strictEqual(result.wordCount, 2); + }); + + test('validateField returns invalid for empty required field', function (assert) { + this.component.stepValidation = { field1: { min: 1, max: 10 } }; + const result = this.component.validateField('field1', ''); + + assert.false(result.isValid); + assert.strictEqual(result.remainingToMin, 1); + }); + + test('validateField returns invalid when exceeds max words', function (assert) { + this.component.stepValidation = { field1: { min: 1, max: 3 } }; + const result = this.component.validateField('field1', 'one two three four'); + assert.false(result.isValid); + }); + + test('isDataValid returns true when all fields valid', function (assert) { + this.component.stepValidation = { + field1: { min: 1, max: 10 }, + field2: { min: 1, max: 5 }, + }; + this.component.data = { field1: 'valid text', field2: 'ok' }; + + const result = this.component.isDataValid(); + assert.true(result); + }); + + test('isDataValid returns false when any field invalid', function (assert) { + this.component.stepValidation = { + field1: { min: 1, max: 10 }, + field2: { min: 1, max: 5 }, + }; + this.component.data = { field1: 'valid text', field2: '' }; + + const result = this.component.isDataValid(); + assert.false(result); + }); + + test('updateFieldValue updates data and syncs storage', function (assert) { + this.component.data = {}; + Object.defineProperty(this.component, 'storageKey', { + value: 'test-key', + writable: true, + configurable: true, + }); + + this.component.updateFieldValue('field1', 'new value'); + assert.strictEqual(this.component.data.field1, 'new value'); + }); + + test('updateWordCount updates word count correctly', function (assert) { + this.component.wordCount = {}; + this.component.updateWordCount('field1', { wordCount: 5 }); + assert.strictEqual(this.component.wordCount.field1, 5); + }); + + test('formatError returns empty string when valid', function (assert) { + this.component.stepValidation = { field1: { min: 1, max: 10 } }; + const result = this.component.formatError('field1', { isValid: true }); + assert.strictEqual(result, ''); + }); + + test('formatError returns min words message', function (assert) { + this.component.stepValidation = { field1: { min: 5, max: 10 } }; + const result = this.component.formatError('field1', { + isValid: false, + remainingToMin: 3, + }); + assert.strictEqual(result, 'At least 3 more word(s) required'); + }); + + test('formatError returns max words message', function (assert) { + this.component.stepValidation = { field1: { min: 1, max: 5 } }; + const result = this.component.formatError('field1', { isValid: false }); + assert.strictEqual(result, 'Maximum 5 words allowed'); + }); + + test('updateErrorMessage updates error correctly', function (assert) { + this.component.stepValidation = { field1: { min: 1, max: 10 } }; + this.component.errorMessage = {}; + this.component.updateErrorMessage('field1', { + isValid: false, + remainingToMin: 2, + }); + + assert.strictEqual( + this.component.errorMessage.field1, + 'At least 2 more word(s) required', + ); + }); +});