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..2ee365ad1
--- /dev/null
+++ b/app/components/new-join-steps/new-step-five.hbs
@@ -0,0 +1,51 @@
+
+
+
+
+{{#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..946dccb36
--- /dev/null
+++ b/app/components/new-join-steps/new-step-four.hbs
@@ -0,0 +1,86 @@
+
+
+
+
+ {{#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..bb6f481f3
--- /dev/null
+++ b/app/components/new-join-steps/new-step-one.hbs
@@ -0,0 +1,66 @@
+
+
+
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-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..7e1a8848f
--- /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-stepper.hbs b/app/components/new-stepper.hbs
index 598cf5475..57da598a5 100644
--- a/app/components/new-stepper.hbs
+++ b/app/components/new-stepper.hbs
@@ -1,18 +1,69 @@
-
+
+ {{#if (not-eq this.currentStep this.MIN_STEP)}}
+
+ {{#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..919c68033 100644
--- a/app/components/new-stepper.js
+++ b/app/components/new-stepper.js
@@ -2,6 +2,8 @@ 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;
@@ -12,8 +14,25 @@ export default class NewStepperComponent extends Component {
@service onboarding;
@service joinApplicationTerms;
- @tracked currentStep =
- Number(localStorage.getItem('currentStep') ?? this.args.step) || 0;
+ @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;
@@ -26,10 +45,27 @@ 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 isNextButtonDisabled() {
+ return !(this.preValid || this.isValid);
+ }
+
@action incrementStep() {
if (this.currentStep < this.MAX_STEP) {
const nextStep = this.currentStep + 1;
- localStorage.setItem('currentStep', String(nextStep));
+ setLocalStorageItem('currentStep', String(nextStep));
+ this.currentStep = nextStep;
this.updateQueryParam(nextStep);
}
}
@@ -37,7 +73,8 @@ export default class NewStepperComponent extends Component {
@action decrementStep() {
if (this.currentStep > this.MIN_STEP) {
const previousStep = this.currentStep - 1;
- localStorage.setItem('currentStep', String(previousStep));
+ setLocalStorageItem('currentStep', String(previousStep));
+ this.currentStep = previousStep;
this.updateQueryParam(previousStep);
}
}
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..b600b0787
--- /dev/null
+++ b/app/constants/new-join-form.js
@@ -0,0 +1,65 @@
+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?',
+ ],
+ 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',
+ ],
+};
+
+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/styles/new-stepper.module.css b/app/styles/new-stepper.module.css
index bbef5d22e..b9da05420 100644
--- a/app/styles/new-stepper.module.css
+++ b/app/styles/new-stepper.module.css
@@ -8,12 +8,19 @@
}
.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;
+}
+
+.next-button {
+ grid-column: 3;
+ justify-self: end;
}
.welcome-screen {
@@ -398,8 +405,7 @@
border-top: 1px solid var(--color-lightgrey);
}
-/* MEDIA QUERIES */
-@media screen and (width <= 1280px) {
+@media screen and (width <=1280px) {
.new-stepper__form,
.form-header,
.new-stepper__buttons {
@@ -407,7 +413,7 @@
}
}
-@media screen and (width <= 1024px) {
+@media screen and (width <=1024px) {
.new-stepper__form,
.form-header,
.new-stepper__buttons {
@@ -415,7 +421,7 @@
}
}
-@media screen and (width <= 768px) {
+@media screen and (width <=768px) {
.two-column-layout {
grid-template-columns: 1fr;
}
@@ -472,7 +478,7 @@
}
}
-@media screen and (width <= 480px) {
+@media screen and (width <=480px) {
.form-header,
.new-stepper__form,
.new-stepper__buttons {
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-join-steps/new-step-five-test.js b/tests/integration/components/new-join-steps/new-step-five-test.js
new file mode 100644
index 000000000..6efe2fb44
--- /dev/null
+++ b/tests/integration/components/new-join-steps/new-step-five-test.js
@@ -0,0 +1,67 @@
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'website-www/tests/helpers';
+
+module(
+ 'Integration | Component | new-join-steps/new-step-five',
+ function (hooks) {
+ setupRenderingTest(hooks);
+
+ hooks.beforeEach(function () {
+ window.localStorage.clear();
+ this.set('setIsPreValid', () => {});
+ this.set('setIsValid', () => {});
+ });
+
+ test('it renders the feedback and discovery fields', async function (assert) {
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-step="new-step-five"]').exists();
+ assert.dom('[data-test-textarea-field][name="whyRds"]').exists();
+ assert.dom('[data-test-input-field][name="numberOfHours"]').exists();
+ assert.dom('[data-test-dropdown-field][name="foundFrom"]').exists();
+ });
+
+ test('it renders heard from options in dropdown', async function (assert) {
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-dropdown-option="Twitter"]').exists();
+ assert.dom('[data-test-dropdown-option="LinkedIn"]').exists();
+ });
+
+ test('it pre-populates saved answers from local storage', async function (assert) {
+ window.localStorage.setItem(
+ 'newStepFiveData',
+ JSON.stringify({
+ whyRds: 'Building real projects',
+ foundFrom: 'Other',
+ }),
+ );
+
+ await render(hbs`
+
+ `);
+
+ assert
+ .dom('[data-test-textarea-field][name="whyRds"]')
+ .hasValue('Building real projects');
+ assert
+ .dom('[data-test-dropdown-field][name="foundFrom"]')
+ .hasValue('Other');
+ });
+ },
+);
diff --git a/tests/integration/components/new-join-steps/new-step-four-test.js b/tests/integration/components/new-join-steps/new-step-four-test.js
new file mode 100644
index 000000000..939c82d1c
--- /dev/null
+++ b/tests/integration/components/new-join-steps/new-step-four-test.js
@@ -0,0 +1,67 @@
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'website-www/tests/helpers';
+import { STEP_DATA_STORAGE_KEY } from 'website-www/constants/new-join-form';
+
+module(
+ 'Integration | Component | new-join-steps/new-step-four',
+ function (hooks) {
+ setupRenderingTest(hooks);
+
+ hooks.beforeEach(function () {
+ window.localStorage.clear();
+ this.set('setIsPreValid', () => {});
+ this.set('setIsValid', () => {});
+ });
+
+ test('it renders base social inputs', async function (assert) {
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-step="new-step-four"]').exists();
+ assert.dom('[data-test-input-field][name="phoneNumber"]').exists();
+ assert.dom('[data-test-input-field][name="twitter"]').exists();
+ assert.dom('[data-test-input-field][name="linkedin"]').exists();
+ assert.dom('[data-test-input-field][name="instagram"]').exists();
+ assert.dom('[data-test-input-field][name="peerlist"]').exists();
+ });
+
+ test('it shows GitHub input when role is developer', async function (assert) {
+ window.localStorage.setItem(
+ STEP_DATA_STORAGE_KEY.stepOne,
+ JSON.stringify({ role: 'Developer' }),
+ );
+
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-input-field][name="github"]').exists();
+ });
+
+ test('it shows designer inputs when role is designer', async function (assert) {
+ window.localStorage.setItem(
+ STEP_DATA_STORAGE_KEY.stepOne,
+ JSON.stringify({ role: 'Designer' }),
+ );
+
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-input-field][name="behance"]').exists();
+ assert.dom('[data-test-input-field][name="dribble"]').exists();
+ });
+ },
+);
diff --git a/tests/integration/components/new-join-steps/new-step-one-test.js b/tests/integration/components/new-join-steps/new-step-one-test.js
new file mode 100644
index 000000000..9369139f7
--- /dev/null
+++ b/tests/integration/components/new-join-steps/new-step-one-test.js
@@ -0,0 +1,132 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import {
+ render,
+ fillIn,
+ click,
+ triggerEvent,
+ settled,
+} from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import Service from '@ember/service';
+import sinon from 'sinon';
+import * as RunLoop from '@ember/runloop';
+import { STEP_DATA_STORAGE_KEY } from 'website-www/constants/new-join-form';
+
+module(
+ 'Integration | Component | new-join-steps/new-step-one',
+ function (hooks) {
+ setupRenderingTest(hooks);
+
+ let clock;
+
+ hooks.beforeEach(function () {
+ clock = sinon.useFakeTimers();
+ localStorage.clear();
+ this.debounceStub = sinon
+ .stub(RunLoop, 'debounce')
+ .callsFake((ctx, fn, ...args) => {
+ fn.apply(ctx, args);
+ });
+
+ class StubLoginService extends Service {
+ userData = { first_name: 'Ada', last_name: 'Lovelace' };
+ }
+ this.owner.register('service:login', StubLoginService);
+ });
+
+ hooks.afterEach(function () {
+ clock.restore();
+ localStorage.clear();
+ this.debounceStub.restore();
+ });
+
+ test('it renders fields, pre-fills full name, and disables the name input', async function (assert) {
+ assert.expect(6);
+
+ this.setProperties({
+ setIsValid: sinon.spy(),
+ setIsPreValid: sinon.spy(),
+ });
+
+ await render(hbs`
+
+ `);
+
+ const nameInput = document.querySelector('input[name="fullName"]');
+ assert.ok(nameInput, 'full name input exists');
+ assert.strictEqual(
+ nameInput?.value,
+ 'Ada Lovelace',
+ 'full name is prefilled',
+ );
+ assert.ok(nameInput?.disabled, 'full name input is disabled');
+
+ assert.ok(
+ document.querySelector('[name="country"]'),
+ 'country dropdown exists',
+ );
+ assert.ok(
+ document.querySelector('input[name="state"]'),
+ 'state input exists',
+ );
+ assert.ok(
+ document.querySelector('input[name="city"]'),
+ 'city input exists',
+ );
+ });
+
+ test('it updates role selection, debounced inputs, and syncs localStorage/validity', async function (assert) {
+ assert.expect(8);
+
+ const setIsValid = sinon.spy();
+ const setIsPreValid = sinon.spy();
+
+ this.setProperties({ setIsValid, setIsPreValid });
+
+ await render(hbs`
+
+ `);
+
+ const roleButtons = Array.from(
+ document.querySelectorAll('[data-test-role-button]'),
+ );
+ assert.ok(roleButtons.length > 0, 'role buttons rendered');
+ await click(roleButtons[0]);
+
+ assert.ok(
+ roleButtons[0].classList.contains('role-button--selected'),
+ 'clicked role button is selected',
+ );
+
+ await fillIn('input[name="state"]', 'Karnataka');
+ await fillIn('input[name="city"]', 'Bengaluru');
+
+ const countryEl = document.querySelector('[name="country"]');
+ countryEl.value = 'India';
+ await triggerEvent(countryEl, 'change');
+
+ assert.ok(
+ setIsPreValid.calledWith(false),
+ 'pre-valid set to false on input',
+ );
+
+ await settled();
+
+ const raw = localStorage.getItem(STEP_DATA_STORAGE_KEY.stepOne);
+ assert.ok(raw, 'new step one data saved in localStorage');
+ const parsed = JSON.parse(raw);
+ assert.strictEqual(parsed.state, 'Karnataka', 'state persisted');
+ assert.strictEqual(parsed.city, 'Bengaluru', 'city persisted');
+ assert.strictEqual(parsed.country, 'India', 'country persisted');
+
+ assert.ok(setIsValid.called, 'validity evaluated and communicated');
+ });
+ },
+);
diff --git a/tests/integration/components/new-join-steps/stepper-header-test.js b/tests/integration/components/new-join-steps/stepper-header-test.js
new file mode 100644
index 000000000..ad58dd82b
--- /dev/null
+++ b/tests/integration/components/new-join-steps/stepper-header-test.js
@@ -0,0 +1,85 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+
+module(
+ 'Integration | Component | new-join-steps/stepper-header',
+ function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders current/total and progress width correctly', async function (assert) {
+ assert.expect(5);
+
+ this.setProperties({ currentStep: 2, totalSteps: 5 });
+
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-current-step]').hasText('2', 'shows current step');
+ assert
+ .dom('[data-test-total-steps]')
+ .hasText('of 5', 'shows total steps');
+
+ const progressEl = document.querySelector('[data-test-progress]');
+ assert.ok(progressEl, 'progress bar exists');
+ assert.strictEqual(
+ progressEl?.getAttribute('aria-valuenow'),
+ '40',
+ 'aria-valuenow reflects 40%',
+ );
+
+ const fill = document.querySelector('[data-test-progress-fill]');
+ assert.ok(
+ fill?.getAttribute('style')?.includes('width: 40%'),
+ 'fill width is 40%',
+ );
+ });
+
+ test('it handles zero and rounding correctly', async function (assert) {
+ assert.expect(3);
+
+ this.setProperties({ currentStep: 0, totalSteps: 1 });
+
+ await render(hbs`
+
+ `);
+
+ const progressEl0 = document.querySelector('[data-test-progress]');
+ assert.strictEqual(
+ progressEl0?.getAttribute('aria-valuenow'),
+ '0',
+ '0% at step 0/1',
+ );
+
+ this.setProperties({ currentStep: 1, totalSteps: 3 });
+
+ await render(hbs`
+
+ `);
+
+ const progressEl33 = document.querySelector('[data-test-progress]');
+ assert.strictEqual(
+ progressEl33?.getAttribute('aria-valuenow'),
+ '33',
+ 'rounded to 33%',
+ );
+
+ const fill = document.querySelector('[data-test-progress-fill]');
+ assert.ok(
+ fill?.getAttribute('style')?.includes('width: 33%'),
+ 'fill width is 33%',
+ );
+ });
+ },
+);