diff --git a/src/actions/collaborationAdsActions.js b/src/actions/collaborationAdsActions.js
new file mode 100644
index 0000000000..5568d81a8e
--- /dev/null
+++ b/src/actions/collaborationAdsActions.js
@@ -0,0 +1,29 @@
+import axios from 'axios';
+import { toast } from 'react-toastify';
+import { ENDPOINTS } from '../utils/URL';
+
+export const CREATE_COLLABORATION_ADS_REQUEST = 'CREATE_COLLABORATION_ADS_REQUEST';
+export const CREATE_COLLABORATION_ADS_SUCCESS = 'CREATE_COLLABORATION_ADS_SUCCESS';
+export const CREATE_COLLABORATION_ADS_FAIL = 'CREATE_COLLABORATION_ADS_FAIL';
+
+// eslint-disable-next-line import/prefer-default-export
+export const createCollaborationAds = formData => async dispatch => {
+ try {
+ dispatch({ type: CREATE_COLLABORATION_ADS_REQUEST });
+ const response = await axios.post(`${ENDPOINTS.JOBS}`, formData);
+
+ dispatch({
+ type: CREATE_COLLABORATION_ADS_SUCCESS,
+ payload: response.data,
+ });
+
+ if (response.status === 200 || response.status === 201) {
+ toast.success('Collaboration Ads created successfully!');
+ }
+ } catch (error) {
+ dispatch({ type: CREATE_COLLABORATION_ADS_FAIL });
+ toast.error(
+ error.response?.data?.message || 'Error updating the details. Please try again.',
+ );
+ }
+};
diff --git a/src/components/Collaboration/Collaboration.jsx b/src/components/Collaboration/Collaboration.jsx
index d92e03c7b4..ea5909b8f9 100644
--- a/src/components/Collaboration/Collaboration.jsx
+++ b/src/components/Collaboration/Collaboration.jsx
@@ -44,6 +44,55 @@ function dedupeJobsByTitle(jobs) {
});
}
+/**
+ * Backend parseCategory() splits on commas, which breaks names like
+ * "Architecture, Landscape & Environment". Sending a JSON array keeps the
+ * full category string intact (API already supports JSON.parse first).
+ */
+function encodeJobsCategoryParam(category) {
+ const trimmed = String(category || '').trim();
+ if (!trimmed) return '';
+ return JSON.stringify([trimmed]);
+}
+
+/** Dropbox share links need raw=1 (or dl=1) to render as
src. */
+function normalizeJobImageUrl(url) {
+ const trimmed = String(url || '').trim();
+ if (!trimmed) return '';
+ try {
+ const parsed = new URL(trimmed);
+ const host = parsed.hostname.replace(/^www\./, '');
+ if (host === 'dropbox.com' || host === 'dl.dropboxusercontent.com') {
+ parsed.searchParams.delete('dl');
+ parsed.searchParams.set('raw', '1');
+ return parsed.toString();
+ }
+ return trimmed;
+ } catch {
+ return trimmed;
+ }
+}
+
+function isUsableJobImageUrl(url) {
+ if (!url || typeof url !== 'string') return false;
+ const trimmed = url.trim();
+ if (!trimmed) return false;
+ try {
+ const parsed = new URL(trimmed);
+ if (!['http:', 'https:'].includes(parsed.protocol)) return false;
+ if (
+ ['example.com', 'www.example.com', 'placeholder.com', 'via.placeholder.com'].includes(
+ parsed.hostname,
+ )
+ ) {
+ return false;
+ }
+ return true;
+ } catch {
+ return false;
+ }
+}
+
function Collaboration() {
const [searchTerm, setSearchTerm] = useState('');
const [submittedSearchTerm, setSubmittedSearchTerm] = useState('');
@@ -175,7 +224,7 @@ function Collaboration() {
const response = await fetch(
`${ApiEndpoint}/jobs?page=${page}&limit=${adsPerPage}` +
`&search=${encodeURIComponent(search)}` +
- `&category=${encodeURIComponent(category)}`,
+ `&category=${encodeURIComponent(encodeJobsCategoryParam(category))}`,
requestOptions,
);
@@ -307,7 +356,7 @@ function Collaboration() {
setActiveTab('jobPostings');
const response = await fetch(
`${ApiEndpoint}/jobs/summaries?search=${encodeURIComponent(searchTerm)}` +
- `&category=${encodeURIComponent(selectedCategory)}`,
+ `&category=${encodeURIComponent(encodeJobsCategoryParam(selectedCategory))}`,
{ method: 'GET' },
);
@@ -354,9 +403,22 @@ function Collaboration() {
}
};
+ const getJobImageSrc = (ad, jobCategory) => {
+ if (isUsableJobImageUrl(ad?.imageUrl)) {
+ return normalizeJobImageUrl(ad.imageUrl);
+ }
+ return getCategoryImage(jobCategory);
+ };
+
const handleImageError = event => {
- event.currentTarget.onerror = null;
- event.currentTarget.src =
+ const img = event.currentTarget;
+ img.onerror = null;
+ const fallback = img.dataset.fallback;
+ if (fallback && img.src !== fallback) {
+ img.src = fallback;
+ return;
+ }
+ img.src =
'https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=640&h=480&fit=crop&q=80';
};
@@ -380,6 +442,7 @@ function Collaboration() {
if (!ad?._id) return null;
const jobTitle = ad.title || 'Untitled Position';
const jobCategory = ad.category || 'General';
+ const categoryFallback = getCategoryImage(jobCategory);
return (
diff --git a/src/components/Collaboration/JobApplicationForm/JobApplicationForm.module.css b/src/components/Collaboration/JobApplicationForm/JobApplicationForm.module.css
index 41c5c1c134..8c5e247c89 100644
--- a/src/components/Collaboration/JobApplicationForm/JobApplicationForm.module.css
+++ b/src/components/Collaboration/JobApplicationForm/JobApplicationForm.module.css
@@ -239,6 +239,29 @@
min-width: 0;
}
+.technologyFollowUps {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 12px;
+}
+
+.technologyFollowUp {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+}
+
+.technologyName {
+ min-width: 140px;
+ font-weight: 600;
+}
+
+.yearsInput {
+ width: 140px;
+}
+
.formProfileDetailGroup {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
diff --git a/src/components/Collaboration/JobApplyLink.jsx b/src/components/Collaboration/JobApplyLink.jsx
new file mode 100644
index 0000000000..01b45e7f22
--- /dev/null
+++ b/src/components/Collaboration/JobApplyLink.jsx
@@ -0,0 +1,181 @@
+import { useSelector } from 'react-redux';
+import { useEffect, useState } from 'react';
+import { toast } from 'react-toastify';
+import { ENDPOINTS } from '../../utils/URL';
+import OneCommunityImage from '../../assets/images/logo2.png';
+import styles from '../Collaboration/JobApplyLink.module.css';
+
+function QuestionOptions({ options, renderOption }) {
+ if (!options?.length) return null;
+ return options.map(option => renderOption(option));
+}
+
+function ApplyQuestionField({ question }) {
+ const { questionType, _id: questionId, options } = question;
+
+ switch (questionType) {
+ case 'textbox':
+ return ;
+ case 'textarea':
+ return ;
+ case 'email':
+ return ;
+ case 'date':
+ return ;
+ case 'file':
+ return ;
+ case 'checkbox':
+ return (
+
+ );
+ case 'radio':
+ return (
+
+ );
+ case 'dropdown':
+ return (
+
+ );
+ default:
+ return another field type {questionType}
;
+ }
+}
+
+function JobApplyLink() {
+ const [jobFormsAll, setJobFormsAll] = useState([]);
+ const [jobForms, setJobForms] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [applyLink, setApplyLink] = useState('');
+ const darkMode = useSelector(state => state.theme.darkMode);
+
+ const fetchJobFormsAll = async () => {
+ try {
+ const response = await fetch(`${ENDPOINTS.GET_ALL_JOB_FORMS}`, {
+ method: 'GET',
+ headers: {
+ Authorization: localStorage.getItem('token'),
+ },
+ });
+ if (!response.ok) throw new Error(`Failed to fetch all jobForms: ${response.statusText}`);
+ const data = await response.json();
+ setJobFormsAll(data.forms);
+ } catch (error) {
+ toast.error(`Error fetching jobFormsAll: ${error.message}`);
+ }
+ };
+
+ const getJobForms = async () => {
+ if (!applyLink) return;
+
+ try {
+ setLoading(true);
+ const selectedFormId = new URL(applyLink).pathname.split('/').pop();
+ const response = await fetch(`${ENDPOINTS.APIEndpoint()}/jobforms/${selectedFormId}`, {
+ method: 'get',
+ headers: {
+ Authorization: localStorage.getItem('token'),
+ },
+ });
+
+ if (!response.ok) throw new Error(`Failed to fetch all Templates: ${response.statusText}`);
+
+ const data = await response.json();
+ setJobForms(data);
+ } catch (error) {
+ toast.error(`Error fetching JobForms: ${error.message}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ void fetchJobFormsAll();
+ }, []);
+
+ useEffect(() => {
+ void getJobForms();
+ }, [applyLink]);
+
+ if (loading) {
+ return 'Loading ';
+ }
+
+ const questions = jobForms.form?.questions ?? [];
+
+ return (
+
+
+
+
+
{jobForms.form?.title}
+
{jobForms.form?.description}
+
+
+
+ );
+}
+
+export default JobApplyLink;
diff --git a/src/components/Collaboration/JobApplyLink.module.css b/src/components/Collaboration/JobApplyLink.module.css
new file mode 100644
index 0000000000..8845a1c155
--- /dev/null
+++ b/src/components/Collaboration/JobApplyLink.module.css
@@ -0,0 +1,37 @@
+
+.lightModeContainer {
+ background-color:#fff;
+ color:#000;
+ margin:10px;
+ padding:10px;
+
+}
+
+.darkModeContainer {
+ background-color:#1e1e1e;
+ color:#fff;
+ margin:10px;
+ padding:10px;
+}
+
+.ApplyLink-header {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.buttonGroup{
+ display:flex;
+ flex-direction: row;
+ justify-content:space-around;
+ gap:3px;
+ padding:15px;
+ margin:50px;
+ border-radius: 5px;
+ border: solid 2px #000;
+}
+
+.buttonGroup button{
+ border:5px;
+}
+
diff --git a/src/components/Collaboration/JobDetailsLink.jsx b/src/components/Collaboration/JobDetailsLink.jsx
new file mode 100644
index 0000000000..b20615599c
--- /dev/null
+++ b/src/components/Collaboration/JobDetailsLink.jsx
@@ -0,0 +1,256 @@
+import { useParams } from 'react-router-dom';
+import { useSelector } from 'react-redux';
+import { useEffect, useState } from 'react';
+import axios from 'axios';
+import { toast } from 'react-toastify';
+import { ApiEndpoint } from '~/utils/URL';
+import { ENDPOINTS } from '../../utils/URL';
+import OneCommunityImage from '../../assets/images/logo2.png';
+import styles from '../Collaboration/JobDetailsLink.module.css';
+import JobFormQuestionField, { isAllowedUploadMimeType } from './jobFormQuestionField';
+
+const DETAIL_HTML_FIELDS = ['description', 'requirements', 'projects', 'ourCommunity'];
+
+function JobDetailsLink() {
+ const { givenId } = useParams();
+ const initialState = { formId: '', answers: [] };
+ const [formData, setFormData] = useState({ ...initialState });
+ const darkMode = useSelector(state => state.theme.darkMode);
+ const [jobsDetailById, setJobsDetailById] = useState({});
+ const [loading, setLoading] = useState(true);
+ const [jobForms, setJobForms] = useState({});
+ const [uploadingFiles, setUploadingFiles] = useState({});
+ const [errors, setErrors] = useState({});
+
+ const getJobDetailsById = async id => {
+ setLoading(true);
+ try {
+ const response = await fetch(`${ApiEndpoint}/jobs/${id}`, { method: 'GET' });
+ if (!response.ok) {
+ throw new Error(`Failed to fetch jobs: ${response.statusText}`);
+ }
+ const data = await response.json();
+ setJobsDetailById(data);
+ } catch (error) {
+ toast.error(`Error fetching jobs Details: ${error.message}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const getJobForms = async applyLink => {
+ const formId = applyLink.split('jobforms/')[1];
+ try {
+ const response = await fetch(`${ENDPOINTS.APIEndpoint()}/jobforms/${formId}`, {
+ method: 'get',
+ });
+ if (!response.ok) throw new Error(`Failed to fetch all jobForms : ${response.statusText}`);
+ const data = await response.json();
+ setJobForms(data);
+ } catch (error) {
+ toast.error(`Error fetching JobForms: ${error.message}`);
+ }
+ };
+
+ useEffect(() => {
+ void getJobDetailsById(givenId);
+ }, [givenId]);
+
+ useEffect(() => {
+ if (!loading && jobsDetailById?.applyLink) {
+ void getJobForms(jobsDetailById.applyLink);
+ const applyLinkFormId = jobsDetailById.applyLink.split('jobforms/')[1];
+ setFormData(prev => ({ ...prev, formId: applyLinkFormId }));
+ }
+ }, [loading, jobsDetailById?.applyLink]);
+
+ const getValue = name => {
+ if (!Array.isArray(formData.answers)) return '';
+ const found = formData.answers.find(a => a.questionId === name || a.label === name);
+ return found ? found.answer : '';
+ };
+
+ const handleChange = (event, idx) => {
+ const { id, name, value } = event.target;
+ setFormData(prev => ({
+ ...prev,
+ answers: Array.isArray(prev.answers)
+ ? [
+ ...prev.answers.filter(a => a.questionId !== id),
+ { questionId: id, questionText: name, answer: value, order: idx },
+ ]
+ : [{ questionId: id, questionText: name, answer: value, order: idx }],
+ }));
+ };
+
+ const handleFileChange = async (event, index) => {
+ const { id, name } = event.target;
+ const selFile = event.target.files[0];
+ if (!selFile) return;
+ if (selFile.size > 5 * 1024 * 1024) {
+ toast.error('File size exceeds 5MB limit. Please choose a smaller file. ');
+ return;
+ }
+ if (!isAllowedUploadMimeType(selFile.type)) {
+ toast.error('Invalid file type. Please upload a PDF, DOC, DOCX, JPG, PNG, or BMP file.');
+ return;
+ }
+ try {
+ setUploadingFiles(prev => ({ ...prev, [name]: true }));
+ const formResumeData = new FormData();
+ formResumeData.append('file', selFile);
+ const formResumeDataResponse = await axios.post(
+ `${ENDPOINTS.APIEndpoint()}/jobforms/responses/upload`,
+ formResumeData,
+ { headers: { 'Content-Type': 'multipart/form-data' } },
+ );
+ const dropboxLink = formResumeDataResponse.data?.data?.url;
+ setFormData(prev => ({
+ ...prev,
+ answers: Array.isArray(prev.answers)
+ ? [
+ ...prev.answers.filter(a => a.questionId !== id),
+ { questionId: id, questionText: name, answer: dropboxLink, order: index },
+ ]
+ : [{ questionId: id, questionText: name, answer: dropboxLink, order: index }],
+ }));
+ } catch (err) {
+ let errorMessage = 'File upload failed.';
+ if (err.response) {
+ errorMessage = err.response.data?.message || `Server error (${err.response.status})`;
+ } else if (err.request) {
+ errorMessage = 'No response from server. Check your connection.';
+ } else {
+ errorMessage = err.message;
+ }
+ toast.error(errorMessage);
+ } finally {
+ setUploadingFiles(prev => ({ ...prev, [name]: false }));
+ }
+ };
+
+ const submitJobforms = async () => {
+ try {
+ setLoading(true);
+ await axios.post(`${ENDPOINTS.APIEndpoint()}/jobforms/responses`, formData);
+ setFormData({ ...initialState });
+ toast.success('Responses submitted successfully');
+ } catch (error) {
+ if (error.response) {
+ toast.error(error.response.status);
+ } else if (error.request) {
+ toast.error('No response from server. Check your connection.');
+ } else {
+ toast.error(`Failed to submit responses: ${error.message}`);
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const inputValidation = () => {
+ const questions = jobForms.form?.questions;
+ if (!questions || !formData?.answers) {
+ return false;
+ }
+
+ for (const question of questions) {
+ const answerObj = formData.answers.find(a => a.questionId === question._id);
+ if (!answerObj?.answer && question.isRequired) {
+ setErrors({
+ [question.questionText]: `${question.questionText} is required`,
+ });
+ toast.error(`${question.questionText} is required`);
+ return false;
+ }
+ setErrors({ [question.questionText]: '' });
+ }
+ return true;
+ };
+
+ const handleSubmit = e => {
+ e.preventDefault();
+ if (!inputValidation()) return;
+ void submitJobforms();
+ };
+
+ const resetForm = e => {
+ e.preventDefault();
+ setFormData({ ...initialState });
+ setErrors({});
+ };
+
+ if (loading) {
+ return (
+
+ Loading
+
+ );
+ }
+
+ const questions = jobForms.form?.questions ?? [];
+
+ return (
+
+
+
+

+
+ {`Job Details for Category: ${jobsDetailById.category},
+ Position: ${jobsDetailById.title}`}
+
+ {DETAIL_HTML_FIELDS.map(
+ key =>
+ jobsDetailById[key] && (
+
+ ),
+ )}
+
+
+
+ );
+}
+
+export default JobDetailsLink;
diff --git a/src/components/Collaboration/JobDetailsLink.module.css b/src/components/Collaboration/JobDetailsLink.module.css
new file mode 100644
index 0000000000..8d576bcd6e
--- /dev/null
+++ b/src/components/Collaboration/JobDetailsLink.module.css
@@ -0,0 +1,162 @@
+.job-details-landing {
+ display: flex;
+ flex-flow: column wrap;
+ align-content: center;
+}
+
+.job-details-header {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.job-details-container {
+ display: flex;
+ width: 90%;
+ margin: 0 auto;
+ flex-direction: column;
+ align-items: stretch;
+ background-color: #fff;
+ border: 1px solid #ddd;
+}
+
+.job-details-card {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid #ddd;
+ margin: 10px;
+ padding: 10px;
+ text-align: justify;
+ font-weight: 500;
+ font-size: 1.5rem;
+ line-height: 1.6;
+}
+
+.job-details-card p {
+ font-size: 30px;
+}
+
+.job-details-card ul {
+ text-align: left;
+ padding-left: 1.5rem;
+ list-style-position: outside;
+}
+
+.job-details-image {
+ width: 100%;
+ height: 100%;
+}
+
+.lightModeContainer {
+ background-color: #fff;
+ color: #000;
+ margin: 10px;
+ padding: 10px;
+}
+
+.darkModeContainer {
+ background-color: #1e1e1e;
+ color: #fff;
+ margin: 10px;
+ padding: 10px;
+}
+
+.ApplyLink-header {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.buttonGroup {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-around;
+ gap: 3px;
+ padding: 15px;
+ margin: 50px;
+ border-radius: 5px;
+ border: solid 2px #000;
+}
+
+.buttonGroup button {
+ border: 5px;
+}
+
+.input-error {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ width: 1000px;
+}
+
+iframe {
+ display: flex;
+}
+
+h1 {
+ width: 100%;
+ margin: 0 auto;
+}
+
+h1 img,
+h2 img,
+h3 img,
+h4 img,
+h5 img,
+h6 img,
+p img {
+ display: block;
+ height: auto;
+ width: 100%;
+ max-width: none;
+}
+
+@media (width <= 768px) {
+ h1 img,
+ h2 img,
+ h3 img,
+ h4 img,
+ h5 img,
+ h6 img,
+ p img {
+ display: block;
+ height: auto;
+ width: 100%;
+ max-width: none;
+ }
+
+ .input-error {
+ width: 100%;
+ }
+}
+
+.job-details-image-card {
+ display: block;
+ height: auto;
+ width: 100%;
+ max-width: none;
+}
+
+.upload-button {
+ display: flex;
+}
+
+.job-form-questions {
+ display: flex;
+ flex-direction: column;
+ width: 70%;
+ margin: 10px;
+ padding: 10px;
+ justify-items: center;
+ gap: 30px;
+}
+
+.user-input {
+ display: flex;
+ flex-direction: row;
+}
+
+.error-message {
+ color: red;
+ font-size: 0.9rem;
+}
diff --git a/src/components/Collaboration/SuggestedJobsList.jsx b/src/components/Collaboration/SuggestedJobsList.jsx
index 7ac846e39b..0abfddec96 100644
--- a/src/components/Collaboration/SuggestedJobsList.jsx
+++ b/src/components/Collaboration/SuggestedJobsList.jsx
@@ -74,7 +74,7 @@ function SuggestedJobsList() {
try {
const url = `${ApiEndpoint}/jobs?page=${currentPage}&limit=${adsPerPage}&search=${encodeURIComponent(
query || '',
- )}&category=${encodeURIComponent(category || '')}`;
+ )}&category=${encodeURIComponent(category ? JSON.stringify([category]) : '')}`;
const response = await fetch(url, { method: 'GET' });
if (!response.ok) throw new Error(`Failed to fetch jobs: ${response.statusText}`);
const data = await response.json();
diff --git a/src/components/Collaboration/__tests__/Collaboration.test.jsx b/src/components/Collaboration/__tests__/Collaboration.test.jsx
index 8f169d30e3..bdc532bdf1 100644
--- a/src/components/Collaboration/__tests__/Collaboration.test.jsx
+++ b/src/components/Collaboration/__tests__/Collaboration.test.jsx
@@ -201,7 +201,9 @@ describe('Collaboration', () => {
await waitFor(() => {
expect(
- globalThis.fetch.mock.calls.some(([url]) => url.includes('category=Engineering')),
+ globalThis.fetch.mock.calls.some(([url]) =>
+ decodeURIComponent(url).includes('category=["Engineering"]'),
+ ),
).toBe(true);
});
});
diff --git a/src/components/Collaboration/__tests__/jobQuestionFollowUps.test.js b/src/components/Collaboration/__tests__/jobQuestionFollowUps.test.js
new file mode 100644
index 0000000000..225cc26a27
--- /dev/null
+++ b/src/components/Collaboration/__tests__/jobQuestionFollowUps.test.js
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import {
+ isDegreeMajorQuestion,
+ isTechnologyExperienceQuestion,
+ missingTechnologyYearMessages,
+ serializeTechnologyAnswer,
+ syncTechnologyDetails,
+} from '../jobQuestionFollowUps';
+
+describe('job question follow-ups', () => {
+ it('hides the degree major question and keeps the other listing questions', () => {
+ expect(isDegreeMajorQuestion({ questionText: 'What is your degree major?' })).toBe(true);
+ expect(
+ isDegreeMajorQuestion({
+ questionText: 'What is the minimum length of time (in months) you are willing to pledge?',
+ }),
+ ).toBe(false);
+ expect(isDegreeMajorQuestion({ questionText: 'What is your location and time zone?' })).toBe(
+ false,
+ );
+ });
+
+ it('recognizes the technology experience question', () => {
+ expect(
+ isTechnologyExperienceQuestion({
+ questionText: 'Which of the following technologies do you have full time experience in',
+ }),
+ ).toBe(true);
+ expect(
+ isTechnologyExperienceQuestion({
+ questionText: 'How many hours a week can you give towards this role?',
+ }),
+ ).toBe(false);
+ });
+
+ it('keeps a full-time flag and years input for every selected technology', () => {
+ const details = syncTechnologyDetails({ 'React.JS': { fullTime: true, years: '3' } }, [
+ 'React.JS',
+ 'Python',
+ ]);
+
+ expect(details).toEqual({
+ 'React.JS': { fullTime: true, years: '3' },
+ Python: { fullTime: false, years: '' },
+ });
+ expect(serializeTechnologyAnswer(['React.JS', 'Python'], details)).toEqual([
+ { technology: 'React.JS', fullTime: true, years: '3' },
+ { technology: 'Python', fullTime: false, years: '' },
+ ]);
+ expect(missingTechnologyYearMessages(['React.JS', 'Python'], details)).toEqual([
+ 'Years of experience for Python',
+ ]);
+ });
+});
diff --git a/src/components/Collaboration/jobAdsCreationValidation.js b/src/components/Collaboration/jobAdsCreationValidation.js
new file mode 100644
index 0000000000..728532527c
--- /dev/null
+++ b/src/components/Collaboration/jobAdsCreationValidation.js
@@ -0,0 +1,67 @@
+import { isValidDropboxImageUrl, isValidUrl } from '../../utils/checkValidURL';
+import getWordCount from '../../utils/getWordCount';
+
+const MIN_WORDS = 30;
+
+const requireField = (value, field, message) => (value ? null : { field, message });
+
+const requireMinWords = (value, field, min, message) =>
+ getWordCount(value) < min ? { field, message } : null;
+
+/**
+ * @returns {{ field: string, message: string } | null}
+ */
+export function validateJobAdsForm(formData) {
+ const checks = [
+ () => requireField(formData.category, 'category', 'Category is required'),
+ () => requireField(formData.title, 'title', 'Title is required'),
+ () => requireField(formData.description, 'description', 'Description is required'),
+ () =>
+ requireMinWords(
+ formData.description,
+ 'description',
+ MIN_WORDS,
+ 'Description must be at least 30 characters long',
+ ),
+ () => requireField(formData.requirements, 'requirements', 'Requirements is required'),
+ () =>
+ requireMinWords(
+ formData.requirements,
+ 'requirements',
+ MIN_WORDS,
+ 'Requirements must be at least 30 words long',
+ ),
+ () => requireField(formData.projects, 'projects', 'Projects is required'),
+ () =>
+ requireMinWords(formData.projects, 'projects', 1, 'Projects must be at least 1 word long'),
+ () => requireField(formData.ourCommunity, 'ourCommunity', 'Our Community is required'),
+ () =>
+ requireMinWords(
+ formData.ourCommunity,
+ 'ourCommunity',
+ MIN_WORDS,
+ 'Our Community must be at least 30 words long',
+ ),
+ () => requireField(formData.imageUrl, 'imageUrl', 'ImageURL is required'),
+ () =>
+ isValidDropboxImageUrl(formData.imageUrl)
+ ? null
+ : { field: 'imageUrl', message: 'Enter a valid ImageURL' },
+ () => requireField(formData.location, 'location', 'Location is required'),
+ () =>
+ formData.location === 'remote'
+ ? null
+ : { field: 'location', message: 'Location should be remote only' },
+ () => requireField(formData.applyLink, 'applyLink', 'Apply Link is required'),
+ () =>
+ isValidUrl(formData.applyLink)
+ ? null
+ : { field: 'applyLink', message: 'Enter the valid Apply Link' },
+ ];
+
+ for (const check of checks) {
+ const error = check();
+ if (error) return error;
+ }
+ return null;
+}
diff --git a/src/components/Collaboration/jobFormQuestionField.jsx b/src/components/Collaboration/jobFormQuestionField.jsx
new file mode 100644
index 0000000000..308084f5df
--- /dev/null
+++ b/src/components/Collaboration/jobFormQuestionField.jsx
@@ -0,0 +1,153 @@
+/**
+ * Shared question-field renderer for collab job test pages
+ * (/jobApplyLink, /jobDetailsLink). Keeps cognitive complexity out of page components.
+ */
+
+const ALLOWED_UPLOAD_MIME_TYPES = new Set([
+ 'application/pdf',
+ 'application/doc',
+ 'application/docx',
+ 'image/jpeg',
+ 'image/png',
+ 'image/bmp',
+]);
+
+export function isAllowedUploadMimeType(mimeType) {
+ return ALLOWED_UPLOAD_MIME_TYPES.has(mimeType);
+}
+
+function OptionList({ options, renderOption }) {
+ if (!options?.length) return null;
+ return options.map(option => renderOption(option));
+}
+
+function FileUploadStatus({ question, uploadingFiles, answers }) {
+ if (uploadingFiles[question.questionText]) {
+ return Uploading
;
+ }
+
+ const uploadedUrl = answers?.find(a => a.questionId === question._id)?.answer;
+ if (!uploadedUrl) return null;
+
+ return (
+ <>
+ File Uploaded Successfully
+
+ View File
+
+ >
+ );
+}
+
+export function JobFormQuestionField({
+ question,
+ index,
+ getValue,
+ onChange,
+ onFileChange,
+ answers = [],
+ uploadingFiles = {},
+}) {
+ const { questionType, _id: questionId, questionText, options } = question;
+ const commonProps = {
+ id: questionId,
+ name: questionText,
+ onChange: e => onChange(e, index),
+ };
+
+ switch (questionType) {
+ case 'text':
+ case 'textbox':
+ return ;
+ case 'textarea':
+ return (
+
+ );
+ case 'email':
+ return ;
+ case 'date':
+ return ;
+ case 'dropdown':
+ return (
+
+ );
+ case 'checkbox':
+ return (
+
+ );
+ case 'radio':
+ return (
+
+ );
+ case 'file':
+ return (
+
+ onFileChange(e, index)}
+ />
+
+
+ );
+ default:
+ return another field type {questionType}
;
+ }
+}
+
+export default JobFormQuestionField;
diff --git a/src/components/Collaboration/jobQuestionFollowUps.js b/src/components/Collaboration/jobQuestionFollowUps.js
new file mode 100644
index 0000000000..dd3b6f18f7
--- /dev/null
+++ b/src/components/Collaboration/jobQuestionFollowUps.js
@@ -0,0 +1,47 @@
+export function questionPrompt(question) {
+ return String(question?.label || question?.questionText || '').toLowerCase();
+}
+
+export function isDegreeMajorQuestion(question) {
+ const text = questionPrompt(question);
+ return text.includes('degree') && text.includes('major');
+}
+
+export function isTechnologyExperienceQuestion(question) {
+ const text = questionPrompt(question);
+ return (
+ text.includes('technolog') &&
+ (text.includes('experience') || text.includes('full-time') || text.includes('full time'))
+ );
+}
+
+export function selectedTechnologyNames(answer) {
+ if (Array.isArray(answer)) return answer.filter(Boolean).map(String);
+ if (answer == null || answer === '') return [];
+ return [String(answer)];
+}
+
+export function syncTechnologyDetails(previousDetails, selectedNames) {
+ const next = {};
+ selectedNames.forEach(name => {
+ next[name] = previousDetails?.[name] || { fullTime: false, years: '' };
+ });
+ return next;
+}
+
+export function serializeTechnologyAnswer(answer, details) {
+ return selectedTechnologyNames(answer).map(technology => ({
+ technology,
+ fullTime: Boolean(details?.[technology]?.fullTime),
+ years: details?.[technology]?.years ?? '',
+ }));
+}
+
+export function missingTechnologyYearMessages(answer, details) {
+ return selectedTechnologyNames(answer)
+ .filter(name => {
+ const years = String(details?.[name]?.years ?? '').trim();
+ return years === '' || Number.isNaN(Number(years)) || Number(years) < 0;
+ })
+ .map(name => `Years of experience for ${name}`);
+}
diff --git a/src/components/Header/Header.jsx b/src/components/Header/Header.jsx
index 1d9caedad9..d4013b98d3 100644
--- a/src/components/Header/Header.jsx
+++ b/src/components/Header/Header.jsx
@@ -47,6 +47,7 @@ import {
BADGE_MANAGEMENT,
BLUE_SQUARE_EMAIL_MANAGEMENT,
DASHBOARD,
+ JOB_ADS_CREATION,
JOB_ANALYTICS_REPORT,
LOGOUT,
OTHER_LINKS,
@@ -220,6 +221,9 @@ export function Header(props) {
!isAuthUser,
);
const canAccessPRDashboard = props.hasPermission(RoutePermissions.prDashboard, !isAuthUser);
+ // Job Ads Creation: Owner/Administrator only
+ const canAccessJobAdsCreation =
+ props.auth.user.role === 'Owner' || props.auth.user.role === 'Administrator';
const userId = user.userid;
const viewerTimeZone = resolveUserTimeZone(props.userProfile?.timeZone);
@@ -835,7 +839,7 @@ export function Header(props) {
)}
- {(canAccessUserManagement || canAccessBadgeManagement || canAccessProjects || canAccessTeams || canAccessPopups || canAccessSendEmails || canAccessScheduleMeetings || canAccessPermissionsManagement || canAccessBlueSquareEmailManagement) && (
+ {(canAccessUserManagement || canAccessBadgeManagement || canAccessProjects || canAccessTeams || canAccessPopups || canAccessSendEmails || canAccessScheduleMeetings || canAccessPermissionsManagement || canAccessBlueSquareEmailManagement || canAccessJobAdsCreation) && (
)}
+ {canAccessJobAdsCreation && (
+
+ {JOB_ADS_CREATION}
+
+ )}
{canAccessPermissionsManagement && (
<>
diff --git a/src/components/PermissionsManagement/Permissions.json b/src/components/PermissionsManagement/Permissions.json
index 16947b155d..079ba96840 100644
--- a/src/components/PermissionsManagement/Permissions.json
+++ b/src/components/PermissionsManagement/Permissions.json
@@ -585,6 +585,17 @@
}
]
},
+ {
+ "label": "CollabJobAds",
+ "description": "Collaboration Job Ads related permissions",
+ "subperms": [
+ {
+ "label": "Create CollabJobAds",
+ "key": "createCollabJobAds",
+ "description": "Make the \"Other Links\" -> \"Job Ads Creation\" button appear/accessible and allow creating collaboration job ads. Intended for Owner and Administrator roles."
+ }
+ ]
+ },
{
"label":"Activity Log Management",
"description":"Category for all permissions related to activity logs",
diff --git a/src/components/common/ProtectedRoute/ProtectedRoute.jsx b/src/components/common/ProtectedRoute/ProtectedRoute.jsx
index 72b40fe630..e510f30b7b 100644
--- a/src/components/common/ProtectedRoute/ProtectedRoute.jsx
+++ b/src/components/common/ProtectedRoute/ProtectedRoute.jsx
@@ -43,7 +43,8 @@ const ProtectedRoute = ({
if (!auth.isAuthenticated) {
return ;
}
- if (routePermissions && !hasPermissionToAccess) {
+ // Enforce either routePermissions and/or allowedRoles when provided
+ if ((routePermissions || allowedRoles) && !hasPermissionToAccess) {
return (
{
+ switch (action.type) {
+ case CREATE_COLLABORATION_ADS_REQUEST:
+ return { ...state, loading: true, error: null };
+ case CREATE_COLLABORATION_ADS_SUCCESS:
+ return { ...state, loading: false, data: action.payload };
+ case CREATE_COLLABORATION_ADS_FAIL:
+ return { ...state, loading: false, error: action.error };
+ default:
+ return state;
+ }
+};
+
+export default collaborationAdsReducer;
diff --git a/src/reducers/index.js b/src/reducers/index.js
index 176586d9b7..03cad41c9b 100644
--- a/src/reducers/index.js
+++ b/src/reducers/index.js
@@ -86,6 +86,7 @@ import { eventFeedbackReducer } from './communityPortal/eventFeedback';
import { noShowVizReducer } from './communityPortal/noShowVizReducer';
import { jobApplicationReducer } from './jobApplication/jobApplicationReducer';
+import collaborationAdsReducer from './collaborationAdsReducer';
import emailOutboxReducer from './emailOutboxReducer';
import emailTemplateReducer from './emailTemplateReducer';
@@ -226,6 +227,7 @@ const localReducers = {
// student tasks
studentTasks: studentTasksReducer,
jobApplication: jobApplicationReducer,
+ collaborationAds: collaborationAdsReducer,
toolReplacement: toolReplacementReducer,
// education portal
diff --git a/src/routes.jsx b/src/routes.jsx
index b0cad32696..6e41b9deab 100644
--- a/src/routes.jsx
+++ b/src/routes.jsx
@@ -106,6 +106,9 @@ import TeamLocations from './components/TeamLocations';
import Inventory from './components/Inventory';
import Collaboration from './components/Collaboration';
import SuggestedJobsList from './components/Collaboration/SuggestedJobsList';
+import JobAdsCreation from './components/Collaboration/JobAdsCreation';
+import JobDetailsLink from './components/Collaboration/JobDetailsLink';
+import JobApplyLink from './components/Collaboration/JobApplyLink';
import TestEventRegistration from './components/EventRegistration/TestEventRegistration';
import MemberList from './components/QuestionnaireDashboard/MemberList';
import PromotionTable from './components/QuestionnaireDashboard/PromotionTable';
@@ -1138,6 +1141,13 @@ export default (
+
+
+
diff --git a/src/utils/URL.js b/src/utils/URL.js
index 5d095a500b..c8feb92b3e 100644
--- a/src/utils/URL.js
+++ b/src/utils/URL.js
@@ -691,6 +691,13 @@ export const ENDPOINTS = {
PROMOTION_ELIGIBILITY: `${APIEndpoint}/promotion-eligibility`,
PROMOTE_MEMBERS: `${APIEndpoint}/promote-members`,
+ // Collaboration - Job Ads endpoints
+ JOBS: `${APIEndpoint}/jobs`,
+ JOB_CATEGORIES: `${APIEndpoint}/jobs/categories`,
+ JOB_POSITIONS: `${APIEndpoint}/jobs/positions`,
+ JOBFORMS_RESPONSES: `${APIEndpoint}/jobforms/responses`,
+ JOBFORMS_RESPONSES_UPLOAD: `${APIEndpoint}/jobforms/responses/upload`,
+
// LinkedIn autoposter endpoints
LINKEDIN_POST: `${APIEndpoint}/postToLinkedIn`,
LINKEDIN_SCHEDULED_POSTS: `${APIEndpoint}/scheduledPosts`,
diff --git a/src/utils/checkValidURL.js b/src/utils/checkValidURL.js
index 8eb38c3373..735ef9a8f6 100644
--- a/src/utils/checkValidURL.js
+++ b/src/utils/checkValidURL.js
@@ -9,3 +9,47 @@ export const isValidMediaUrl = url => {
const urlPattern = /^(?:https?:\/\/)?[\w.-]+\.[a-zA-Z]{2,}(?:\/\S*)?$/;
return urlPattern.test(trimmedUrl);
};
+
+const DROPBOX_HOSTS = new Set([
+ 'www.dropbox.com',
+ 'dropbox.com',
+ 'dl.dropboxusercontent.com',
+ 'www.dropboxusercontent.com',
+]);
+
+export const isValidDropboxImageUrl = string => {
+ try {
+ const url = new URL(string);
+ if (!(url.protocol === 'http:' || url.protocol === 'https:')) return false;
+ if (!url.hostname?.includes('.')) return false;
+ if (!DROPBOX_HOSTS.has(url.hostname)) return false;
+ return /\.(jpg|jpeg|png|gif|webp)$/i.test(url.pathname);
+ } catch {
+ return false;
+ }
+};
+
+export const isValidDropboxDocUrl = string => {
+ try {
+ const url = new URL(string);
+ if (!(url.protocol === 'http:' || url.protocol === 'https:')) return false;
+ if (!url.hostname?.includes('.')) return false;
+ if (!DROPBOX_HOSTS.has(url.hostname)) return false;
+ return /\.(doc|pdf|docx|odt|rtf|txt)$/i.test(url.pathname);
+ } catch {
+ return false;
+ }
+};
+
+export const isValidUrl = string => {
+ try {
+ const url = new URL(string);
+ if (!(url.protocol === 'http:' || url.protocol === 'https:')) return false;
+ if (!url.hostname) return false;
+ const isLocalOrIp = url.hostname === 'localhost' || /^[\d.]+$/.test(url.hostname);
+ if (!isLocalOrIp && !url.hostname.includes('.')) return false;
+ return true;
+ } catch {
+ return false;
+ }
+};
diff --git a/src/utils/routePermissions.js b/src/utils/routePermissions.js
index ae46edde0b..5ea92d87dc 100644
--- a/src/utils/routePermissions.js
+++ b/src/utils/routePermissions.js
@@ -73,5 +73,6 @@ const RoutePermissions = {
permissions.editFormQuestions,
permissions.deleteFormQuestions,
],
+ createCollabJobAds: [permissions.createCollabJobAds],
};
export default RoutePermissions;