311 lines
10 KiB
JavaScript
311 lines
10 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { ClipLoader } from 'react-spinners';
|
||
import authFetch from '../utils/authFetch.js';
|
||
|
||
|
||
function mapScores(riaSecScores) {
|
||
const map = {};
|
||
// e.g. area = "Realistic" => letter "R"
|
||
riaSecScores.forEach(obj => {
|
||
const letter = obj.area[0].toUpperCase(); // 'R', 'I', 'A', 'S', 'E', 'C'
|
||
map[letter] = obj.score;
|
||
});
|
||
return map; // e.g. { R:15, I:22, A:20, S:30, E:25, C:24 }
|
||
}
|
||
|
||
|
||
const InterestInventory = () => {
|
||
const [questions, setQuestions] = useState([]);
|
||
const [responses, setResponses] = useState({});
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
const [userProfile, setUserProfile] = useState(null);
|
||
const isProd = (process.env.REACT_APP_ENV_NAME || '').toLowerCase() === 'prod';
|
||
|
||
const navigate = useNavigate();
|
||
|
||
const questionsPerPage = 6;
|
||
const totalPages = Math.ceil(questions.length / questionsPerPage) || 1;
|
||
|
||
useEffect(() => {
|
||
fetchQuestions();
|
||
fetchUserProfile();
|
||
}, []);
|
||
|
||
const fetchQuestions = async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const response = await authFetch('/api/onet/questions?start=1&end=60', {
|
||
method: 'GET',
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to fetch questions: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (data && Array.isArray(data.questions)) {
|
||
setQuestions(data.questions);
|
||
} else {
|
||
throw new Error('Invalid question format.');
|
||
}
|
||
} catch (err) {
|
||
setError(err.message);
|
||
console.error('Error fetching questions:', err.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const fetchUserProfile = async () => {
|
||
try {
|
||
const res = await authFetch('/api/user-profile', { method: 'GET' });
|
||
if (!res || !res.ok) throw new Error('Failed to fetch user profile');
|
||
const data = await res.json();
|
||
setUserProfile(data);
|
||
} catch (err) {
|
||
console.error('Error fetching user profile:', err.message);
|
||
}
|
||
};
|
||
|
||
// Restore previously saved answers if available
|
||
useEffect(() => {
|
||
const storedAnswers = userProfile?.interest_inventory_answers;
|
||
if (questions.length === 60 && storedAnswers?.length === 60) {
|
||
const restored = {};
|
||
storedAnswers.split('').forEach((val, index) => {
|
||
restored[index + 1] = val;
|
||
});
|
||
setResponses(restored);
|
||
}
|
||
}, [questions, userProfile]);
|
||
|
||
const handleResponseChange = (questionIndex, value) => {
|
||
setResponses((prev) => ({
|
||
...prev,
|
||
[questionIndex]: value,
|
||
}));
|
||
};
|
||
|
||
const validateCurrentPage = () => {
|
||
const start = (currentPage - 1) * questionsPerPage;
|
||
const end = currentPage * questionsPerPage;
|
||
const currentQuestions = questions.slice(start, end);
|
||
|
||
const unanswered = currentQuestions.filter(
|
||
(q) => !responses[q.index] || responses[q.index] === '0'
|
||
);
|
||
if (unanswered.length > 0) {
|
||
alert('Please answer all questions before proceeding.');
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
const handleNextPage = () => {
|
||
if (!validateCurrentPage()) return;
|
||
setCurrentPage((prev) => prev + 1);
|
||
};
|
||
|
||
const handlePreviousPage = () => {
|
||
if (currentPage > 1) {
|
||
setCurrentPage((prev) => prev - 1);
|
||
}
|
||
};
|
||
|
||
const randomizeAnswers = () => {
|
||
const randomized = {};
|
||
questions.forEach((question) => {
|
||
randomized[question.index] = Math.floor(Math.random() * 5) + 1; // 1–5
|
||
});
|
||
setResponses(randomized);
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!validateCurrentPage()) return;
|
||
|
||
// Combine answers into a 60-char string
|
||
const answers = Array.from({ length: 60 }, (_, i) => responses[i + 1] || '0').join('');
|
||
|
||
// First save the answers to user profile
|
||
try {
|
||
await authFetch('/api/user-profile', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
firstName: userProfile?.firstname,
|
||
lastName: userProfile?.lastname,
|
||
email: userProfile?.email,
|
||
zipCode: userProfile?.zipcode,
|
||
state: userProfile?.state,
|
||
area: userProfile?.area,
|
||
careerSituation: userProfile?.career_situation || null,
|
||
interest_inventory_answers: answers,
|
||
}),
|
||
});
|
||
} catch (err) {
|
||
console.error('Error saving answers to user profile:', err.message);
|
||
}
|
||
|
||
// Then submit to the O*Net logic
|
||
try {
|
||
setIsSubmitting(true);
|
||
setError(null);
|
||
const response = await authFetch('/api/onet/submit_answers', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ answers }),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to submit answers: ${response.statusText}`);
|
||
}
|
||
const data = await response.json();
|
||
const { careers: careerSuggestions, riaSecScores } = data;
|
||
|
||
if (Array.isArray(careerSuggestions) && Array.isArray(riaSecScores)) {
|
||
// 4) Convert those scores to a short code
|
||
const scoresMap = mapScores(riaSecScores); // { R:15, I:22, ... }
|
||
await authFetch('/api/user-profile', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
riasec_scores: scoresMap // store in DB as a JSON string
|
||
}),
|
||
});
|
||
|
||
navigate('/career-explorer', { state: { careerSuggestions, riaSecScores, fromInterestInventory: true } });
|
||
} else {
|
||
throw new Error('Invalid data format from the server.');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error submitting answers:', error.message);
|
||
alert('Failed to submit answers. Please try again later.');
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// Compute which questions to show
|
||
const start = (currentPage - 1) * questionsPerPage;
|
||
const end = currentPage * questionsPerPage;
|
||
const currentQuestions = questions.slice(start, end);
|
||
|
||
// Calculate progress for the bar
|
||
const totalQuestions = 60;
|
||
const answeredCount = Object.keys(responses).filter((key) => responses[key] !== '0').length;
|
||
const progressPercent = Math.round((answeredCount / totalQuestions) * 100);
|
||
|
||
return (
|
||
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-50 p-4">
|
||
{/* Card Container */}
|
||
<div className="w-full max-w-xl rounded bg-white p-6 shadow-md">
|
||
<h2 className="mb-4 text-center text-2xl font-semibold">
|
||
Interest Inventory
|
||
</h2>
|
||
|
||
{/* Loading & Error States */}
|
||
{loading && (
|
||
<div className="flex justify-center">
|
||
<ClipLoader size={35} color="#4A90E2" />
|
||
</div>
|
||
)}
|
||
{error && (
|
||
<p className="mb-4 rounded bg-red-50 p-2 text-sm text-red-600">
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
{/* Progress Bar & Page Indicator */}
|
||
<div className="mb-4">
|
||
<p className="text-sm text-gray-600">
|
||
Page {currentPage} of {totalPages}
|
||
</p>
|
||
<div className="mt-2 h-2 w-full overflow-hidden rounded bg-gray-200">
|
||
<div
|
||
className="h-full bg-blue-600 transition-all"
|
||
style={{ width: `${progressPercent}%` }}
|
||
/>
|
||
</div>
|
||
<p className="mt-1 text-right text-xs text-gray-500">
|
||
{answeredCount} / {totalQuestions} answered
|
||
</p>
|
||
</div>
|
||
|
||
{/* Questions */}
|
||
<div className="space-y-4">
|
||
{currentQuestions.map((question) => (
|
||
<div key={question.index} className="flex flex-col">
|
||
<label className="mb-1 font-medium text-gray-700">
|
||
{question.text}
|
||
</label>
|
||
<select
|
||
className="rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none"
|
||
onChange={(e) => handleResponseChange(question.index, e.target.value)}
|
||
value={responses[question.index] || '0'}
|
||
>
|
||
<option value="0">Select an option</option>
|
||
<option value="1">Strongly Dislike</option>
|
||
<option value="2">Dislike</option>
|
||
<option value="3">Neutral</option>
|
||
<option value="4">Like</option>
|
||
<option value="5">Strongly Like</option>
|
||
</select>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Pagination / Action Buttons */}
|
||
<div className="mt-6 flex flex-wrap items-center justify-between space-y-2 sm:space-y-0">
|
||
<div className="flex space-x-2">
|
||
{currentPage > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={handlePreviousPage}
|
||
className="rounded bg-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-400"
|
||
>
|
||
Previous
|
||
</button>
|
||
)}
|
||
{currentPage < totalPages ? (
|
||
<button
|
||
type="button"
|
||
onClick={handleNextPage}
|
||
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||
>
|
||
Next
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={handleSubmit}
|
||
disabled={isSubmitting}
|
||
className="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700 disabled:bg-green-300"
|
||
>
|
||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{!isProd && (
|
||
<button
|
||
type="button"
|
||
onClick={randomizeAnswers}
|
||
disabled={isSubmitting}
|
||
className="rounded bg-orange-500 px-4 py-2 text-white hover:bg-orange-600 disabled:bg-orange-300"
|
||
>
|
||
Randomize Answers
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default InterestInventory;
|