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 @@
+
+
+
+
+ {{#if this.errorMessage.whyRds}}
+
{{this.errorMessage.whyRds}}
+ {{/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 @@
+
+
+
+
+ {{#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}}
+
+

+
+
+ {{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 @@
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+ 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:
+
+ {{if this.stepData.three.hobbies this.stepData.three.hobbies 'Not provided'}}
+
+
+
+ Fun Fact:
+
+ {{if this.stepData.three.funFact this.stepData.three.funFact 'Not provided'}}
+
+
+
+
+
+
+
+
+
+ Phone Number:
+
+ {{if this.stepData.four.phoneNumber this.stepData.four.phoneNumber 'Not provided'}}
+
+
+
+ Twitter:
+
+
+ {{#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 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
+
+
+
+
+
+
+
+
+ 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 (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',
+ );
+ });
+});