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 ( + + + + + ); +} + +export default JobAdsCreation; diff --git a/src/components/Collaboration/JobAdsCreation.module.css b/src/components/Collaboration/JobAdsCreation.module.css new file mode 100644 index 0000000000..35e96cef50 --- /dev/null +++ b/src/components/Collaboration/JobAdsCreation.module.css @@ -0,0 +1,245 @@ +.jobAds-header { + display: flex; + justify-content: center; + align-items: center; + width: 100%; +} + +.jobAds-header a { + display: block; + padding: 0; + margin: 0 auto; + width: fit-content; +} + +.jobAds-header img { + width: 60%; + max-width: 500px; + height: auto; + display: block; + margin: 0 auto; + object-fit: contain; +} + +.jobAds-creation { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + margin: 0 auto; + padding: 20px; + gap: 20px; + background-color: #f9f9f9; +} + +.jobAds-creation .header img { + height: auto; + margin: 0 auto; + flex-direction: column; +} + +.jobAds-creation .h3 { + justify-content: center; + display: flex; + align-items: center; +} + +.title-header { + display: flex; + justify-content: center; +} + +.input-error { + display: flex; + flex-direction: row; + align-items: flex-start; + width: 1000px; + padding-left: 50px; + padding-top: 10px; + margin-left: 50px; +} + +.input-item { + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: center; + margin-bottom: 1rem; + text-align: left; + background-color: blueviolet; +} + +.input-label { + display: flex; + align-items: center; + width: 200px; + text-align: justify; +} + +.jobAds-input { + max-width: 100%; + width: 700px; +} + +input { + width: 100%; + height: 30px; +} + +.error { + color: #c62828; + font-size: 0.9em; + margin-top: 5px; + margin-bottom: 10px; + text-align: center; +} + +.jobAds-creation-button-group { + margin-top: 20px; + display: flex; + width: 90%; + justify-content: space-evenly; +} + +.submit-button, +.cancel-button { + padding: 10px; + font-size: 1em; + cursor: pointer; + transition: background 0.2s ease; +} + +.jobAds-creation-container { + display: flex; + flex-direction: column; + justify-items: center; + width: 90%; + margin: 0 auto; + background-color: #fff; + border: 1px solid #ddd; +} + +.user-collaboration-dark-mode { + background-color: #121212; + color: #e0e0e0; +} + +.user-collaboration-dark-mode .title-header h3 { + color: #e0e0e0; +} + +.user-collaboration-dark-mode .jobAds-creation-container { + background-color: #1e1e1e; + border-color: #444; +} + +.user-collaboration-dark-mode .jobAds-creation-container .jobAds-creation-button-group { + background-color: #2a2a2a; + color: #e0e0e0; + border: 1px solid #445a77; +} + +.user-collaboration-dark-mode h2 { + text-align: center; + color: #1e88e5; + margin-bottom: 2rem; +} + +.user-collaboration-dark-mode label { + color: #b0bec5; +} + +.user-collaboration-dark-mode input, +.user-collaboration-dark-mode select, +.user-collaboration-dark-mode textarea { + background-color: #2a2a2a; + color: #e0e0e0; + border: 1px solid #555; +} + +.user-collaboration-dark-mode .error { + color: #ff8a80; +} + +.user-collaboration-dark-mode .jobAds-editor :global(.tox-tinymce) { + border-color: #555; +} + +.user-collaboration-dark-mode .jobAds-creation-container .input-label { + background-color: transparent; + color: #a0a0a0; +} + +.user-collaboration-dark-mode textarea::placeholder, +.user-collaboration-dark-mode select::placeholder, +.user-collaboration-dark-mode input::placeholder { + color: #888; +} + +.user-collaboration-dark-mode .jobAds-creation-button-group .submit-button { + background-color: #0d47a1; + color: #fff; + border: none; + padding: 0.7rem 1.5rem; + border-radius: 6px; + cursor: pointer; + font-weight: bold; + transition: background 0.2s ease; +} + +.user-collaboration-dark-mode .jobAds-creation-button-group .submit-button:hover { + background-color: #1565c0; +} + +.user-collaboration-dark-mode .jobAds-creation-button-group .cancel-button { + background-color: #555; + color: #fff; + border: none; + padding: 0.7rem 1.5rem; + border-radius: 6px; + cursor: pointer; + font-weight: bold; + transition: background 0.2s ease; +} + +.user-collaboration-dark-mode .jobAds-creation-button-group .cancel-button:hover { + background-color: #666; +} + +@media (width <= 768px) { + .jobAds-creation { + padding: 10px; + } + + .jobAds-input { + width: 100%; + } + + .input-error { + width: 100%; + } + + .input-item { + flex-direction: column; + align-items: center; + } + + .input-label { + width: 100px; + font-size: 0.9em; + } + + .submit-button, + .cancel-button { + width: 100px; + font-size: 0.9em; + padding: 8px 16px; + } + + .jobAds-creation-container { + width: 100%; + padding-top: 10px; + padding-bottom: 50px; + } +} diff --git a/src/components/Collaboration/JobApplicationForm/JobApplicationForm.jsx b/src/components/Collaboration/JobApplicationForm/JobApplicationForm.jsx index 6239796be4..96e034440a 100644 --- a/src/components/Collaboration/JobApplicationForm/JobApplicationForm.jsx +++ b/src/components/Collaboration/JobApplicationForm/JobApplicationForm.jsx @@ -11,6 +11,14 @@ import 'react-toastify/dist/ReactToastify.css'; import { parsePhoneNumberFromString } from 'libphonenumber-js/max'; import moment from 'moment-timezone'; import { isJobApplicationFileUploadQuestion } from './jobApplicationQuestionUtils'; +import { + isDegreeMajorQuestion, + isTechnologyExperienceQuestion, + missingTechnologyYearMessages, + serializeTechnologyAnswer, + syncTechnologyDetails, + selectedTechnologyNames, +} from '../jobQuestionFollowUps'; function normalizeTitleKey(s) { return String(s || '') @@ -198,6 +206,7 @@ function isResumeQuestion(q) { /** Resume/CV and built-in profile prompts are collected above — skip duplicates in the list. */ function shouldHideQuestionFromApplicantList(q) { if (isResumeQuestion(q)) return true; + if (isDegreeMajorQuestion(q)) return true; if (isStandardProfileFieldQuestion(q)) return true; const raw = (q.label || q.questionText || '').trim(); return /^(19|20)[.)\s]/.test(raw) || /^question\s*(19|20)\b/i.test(raw); @@ -504,6 +513,7 @@ function collectMissingRequiredFields({ questionFiles, resumeFile, resumeRequired, + technologyExperience, }) { const missing = Object.values(profileErrors); @@ -516,11 +526,17 @@ function collectMissingRequiredFields({ const hoursError = validateHoursPerWeekAnswer(label, answers[idx]); if (hoursError) missing.push(hoursError); } + if (isTechnologyExperienceQuestion(q)) { + missing.push(...missingTechnologyYearMessages(answers[idx], technologyExperience?.[idx])); + } } return missing; } -function serializeAnswerForSubmit(q, idx, answers, questionFiles) { +function serializeAnswerForSubmit(q, idx, answers, questionFiles, technologyExperience) { + if (isTechnologyExperienceQuestion(q)) { + return serializeTechnologyAnswer(answers[idx], technologyExperience?.[idx]); + } if (!isJobApplicationFileUploadQuestion(q)) return answers[idx]; const file = questionFiles[idx]; if (!file) return ''; @@ -670,6 +686,7 @@ function JobApplicationForm() { const [websiteSocial, setWebsiteSocial] = useState(''); const [resumeFile, setResumeFile] = useState(null); const [questionFiles, setQuestionFiles] = useState({}); + const [technologyExperience, setTechnologyExperience] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const resumeInputRef = useRef(null); const questionFileInputRefs = useRef({}); @@ -845,6 +862,7 @@ function JobApplicationForm() { const qs = getVisibleQuestionsForForm(form); setAnswers(initialAnswersForQuestions(qs)); setQuestionFiles({}); + setTechnologyExperience({}); questionFileInputRefs.current = {}; setFieldErrors({}); }, [selectedJob, forms]); @@ -895,13 +913,36 @@ function JobApplicationForm() { }; /** Checkbox question with multiple options: toggle selection in an array stored at answers[idx]. */ - const toggleCheckboxOption = (idx, opt) => { + const toggleCheckboxOption = (idx, opt, trackTechnology = false) => { const prev = answers[idx]; const arr = normalizeCheckboxAnswerArray(prev); const i = arr.indexOf(opt); if (i >= 0) arr.splice(i, 1); else arr.push(opt); handleAnswerChange(idx, arr); + if (trackTechnology) { + setTechnologyExperience(prevDetails => ({ + ...prevDetails, + [idx]: syncTechnologyDetails(prevDetails[idx], arr), + })); + } + }; + + const updateTechnologyDetail = (idx, technology, patch) => { + setTechnologyExperience(prev => { + const row = prev[idx] ?? {}; + const existing = row[technology] ?? { fullTime: false, years: '' }; + return { + ...prev, + [idx]: { + ...row, + [technology]: { + ...existing, + ...patch, + }, + }, + }; + }); }; const isCheckboxOptionChecked = (answer, opt) => { @@ -1071,6 +1112,7 @@ function JobApplicationForm() { questionFiles, resumeFile, resumeRequired, + technologyExperience, }); return { missing, profileErrors }; @@ -1088,6 +1130,7 @@ function JobApplicationForm() { setResumeFile(null); if (resumeInputRef.current) resumeInputRef.current.value = ''; setQuestionFiles({}); + setTechnologyExperience({}); questionFileInputRefs.current = {}; setFullTimeYears(''); setMonthsVolunteer(''); @@ -1158,7 +1201,7 @@ function JobApplicationForm() { }, answers: visibleQuestions.map((q, idx) => ({ questionId: q._id, - answer: serializeAnswerForSubmit(q, idx, answers, questionFiles), + answer: serializeAnswerForSubmit(q, idx, answers, questionFiles, technologyExperience), })), }), ); @@ -1679,13 +1722,55 @@ function JobApplicationForm() { name={`question-${formKey}-${String(opt)}`} value={opt} checked={isCheckboxOptionChecked(answers[idx], opt)} - onChange={() => toggleCheckboxOption(idx, opt)} + onChange={() => + toggleCheckboxOption(idx, opt, isTechnologyExperienceQuestion(q)) + } />{' '} {opt} ))} )} + {isTechnologyExperienceQuestion(q) && + selectedTechnologyNames(answers[idx]).length > 0 && ( +
+ {selectedTechnologyNames(answers[idx]).map(technology => ( +
+ {technology} + + +
+ ))} +
+ )} {qt === 'radio' && q.options && q.options.length > 0 && (
- {isSubmitting ? 'Submitting…' : 'Submit your application'} + {isSubmitting ? 'Submitting…' : 'Submit now'} 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