464 lines
16 KiB
JavaScript
464 lines
16 KiB
JavaScript
import React, { useState, useEffect, useRef } from "react";
|
||
import authFetch from "../utils/authFetch.js";
|
||
|
||
const isoToday = new Date().toISOString().slice(0,10); // top-level helper
|
||
|
||
function buildInterviewPrompt(careerName, jobDescription = "") {
|
||
return `
|
||
You are an expert interviewer for the role **${careerName}**.
|
||
Ask one challenging behavioural or technical question **specific to this career**,
|
||
wait for the candidate's reply, then:
|
||
|
||
• Score the answer 1–5
|
||
• Give concise feedback (1-2 sentences)
|
||
• Ask the next question (up to 5 total)
|
||
|
||
After 5 questions or if the user types "quit interview", end the session.
|
||
|
||
Do NOT output milestones JSON.`;
|
||
}
|
||
|
||
/* ----------------------------------------------
|
||
Hidden prompts for the quick-action buttons
|
||
---------------------------------------------- */
|
||
|
||
function planPrompt( _opts={}) {
|
||
return `
|
||
# ⛔️ DO NOT wrap in markdown, do NOT add back-ticks, do NOT add commentary.
|
||
# ⛔️ Respond with ONE thing only: the VALID JSON object below.
|
||
# ⛔️ The very first character of your reply MUST be “{”.
|
||
{
|
||
"milestones":[
|
||
{
|
||
"title":"", /* GPT must fill — make unique & action-oriented */
|
||
"date":"YYYY-MM-DD", /* ≤ 6 mo, ≥ ${isoToday} */
|
||
"description":"1-2 sentences",
|
||
"impacts":[],
|
||
"tasks":[ … ]
|
||
},
|
||
{
|
||
"title":"", /* GPT must fill — phase-2 name */
|
||
"date":"YYYY-MM-DD", /* 9-18 mo out */
|
||
"description":"1-2 sentences",
|
||
"impacts":[],
|
||
"tasks":[ … ]
|
||
}
|
||
]
|
||
}
|
||
/* Rules — read carefully:
|
||
• You must provide DISTINCT, action-oriented titles.
|
||
• Titles may NOT duplicate any existing milestone (case-insensitive).
|
||
• Include at least one concrete noun (event, cert, org, etc.).
|
||
*/
|
||
`.trim();
|
||
}
|
||
|
||
const QUICK_PROMPTS = {
|
||
networking: ({ careerName, goalsText }) => `
|
||
MODE : networking_plan
|
||
ROLE : ${careerName}
|
||
GOALS: ${goalsText || "N/A"}
|
||
|
||
Create three milestones that expand professional connections.
|
||
${planPrompt()}
|
||
`.trim(),
|
||
|
||
jobSearch: ({ careerName, goalsText }) => `
|
||
MODE : job_search_plan
|
||
ROLE : ${careerName}
|
||
GOALS: ${goalsText || "N/A"}
|
||
|
||
Draft three milestones that accelerate the job hunt.
|
||
${planPrompt()}
|
||
`.trim(),
|
||
|
||
aiGrowth: ({ careerName, skillsText = "N/A", goalsText = "N/A" }) => `
|
||
MODE: ai_growth
|
||
TODAY = ${isoToday}
|
||
|
||
ROLE : ${careerName}
|
||
SKILLS : ${skillsText}
|
||
GOALS : ${goalsText}
|
||
|
||
INSTRUCTIONS
|
||
1. List 2-3 role tasks already touched by AI.
|
||
– For each: how to *collaborate* with AI + one human-edge upskilling step.
|
||
2. Surface adjacent AI-created roles.
|
||
3. End with a 90-day action plan (bullets, dates ≥ TODAY).
|
||
|
||
${planPrompt({ label: "AI Growth" })}
|
||
`.trim(),
|
||
|
||
interview: `
|
||
MODE: interview
|
||
You are an expert interview coach.
|
||
Ask one behavioural or technical question, wait for the user's reply,
|
||
score 1-5, give concise feedback, then ask the next question.
|
||
Stop after 5 questions or if the user types "quit interview".
|
||
Do NOT output milestones JSON.`.trim()
|
||
};
|
||
|
||
|
||
|
||
export default function CareerCoach({
|
||
userProfile,
|
||
financialProfile,
|
||
scenarioRow,
|
||
setScenarioRow,
|
||
careerProfileId,
|
||
collegeProfile,
|
||
onMilestonesCreated,
|
||
onAiRiskFetched
|
||
}) {
|
||
/* -------------- state ---------------- */
|
||
const [messages, setMessages] = useState([]);
|
||
const [input, setInput] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [aiRisk, setAiRisk] = useState(null);
|
||
const chatRef = useRef(null);
|
||
const [showGoals , setShowGoals ] = useState(false);
|
||
const [draftGoals, setDraftGoals] = useState(scenarioRow?.career_goals || "");
|
||
const [saving , setSaving ] = useState(false);
|
||
|
||
/* -------------- scroll --------------- */
|
||
useEffect(() => {
|
||
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
||
}, [messages]);
|
||
|
||
// chat history persistence
|
||
useEffect(() => {
|
||
const saved = localStorage.getItem('coachChat:'+careerProfileId);
|
||
if (saved) setMessages(JSON.parse(saved));
|
||
}, [careerProfileId]);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem('coachChat:'+careerProfileId, JSON.stringify(messages.slice(-20)));
|
||
}, [messages, careerProfileId]);
|
||
|
||
/* -------------- intro ---------------- */
|
||
useEffect(() => {
|
||
if (!scenarioRow) return;
|
||
setMessages(prev =>
|
||
prev.length ? prev // keep what we loaded
|
||
: [generatePersonalizedIntro()] );
|
||
}, [scenarioRow?.id]);
|
||
|
||
/* ---------- helpers you already had ---------- */
|
||
function buildStatusSituationMessage(status, situation, careerName) {
|
||
/* (unchanged body) */
|
||
const sStatus = (status || "").toLowerCase();
|
||
const sSituation = (situation || "").toLowerCase();
|
||
let nowPart = "";
|
||
switch (sStatus) {
|
||
case "planned":
|
||
nowPart = `It appears you’re looking ahead to a possible future as it pertains to ${careerName}.`;
|
||
break;
|
||
case "current":
|
||
nowPart = `It appears you’re currently working in a role as it pertains to ${careerName}.`;
|
||
break;
|
||
case "exploring":
|
||
nowPart = `It appears you’re exploring how ${careerName} might fit your plans.`;
|
||
break;
|
||
default:
|
||
nowPart = `I don’t have a clear picture of your involvement with ${careerName}, but I’m here to help.`;
|
||
}
|
||
let nextPart = "";
|
||
switch (sSituation) {
|
||
case "planning":
|
||
nextPart = `You're aiming to clarify your strategy for moving into this field.`;
|
||
break;
|
||
case "preparing":
|
||
nextPart = `You're actively developing the skills you need for new opportunities.`;
|
||
break;
|
||
case "enhancing":
|
||
nextPart = `You’d like to deepen or broaden your responsibilities.`;
|
||
break;
|
||
case "retirement":
|
||
nextPart = `You're considering how to transition toward retirement.`;
|
||
break;
|
||
default:
|
||
nextPart = `I'm not entirely sure of your next direction, but we’ll keep your background in mind.`;
|
||
}
|
||
const friendlyNote = `
|
||
Feel free to use AptivaAI however it best suits you—there’s no "wrong" answer.
|
||
It's really about where you want to go from here (that's all you can control anyway).
|
||
We can refine details anytime or just jump straight to what you're most interested in exploring now!`;
|
||
return `${nowPart} ${nextPart}\n${friendlyNote}`;
|
||
}
|
||
|
||
function generatePersonalizedIntro() {
|
||
/* (unchanged body) */
|
||
const careerName = scenarioRow?.career_name || "this career";
|
||
const goalsText = scenarioRow?.career_goals?.trim() || null;
|
||
const riskLevel = scenarioRow?.riskLevel;
|
||
const riskReasoning = scenarioRow?.riskReasoning;
|
||
const userSituation = userProfile?.career_situation?.toLowerCase();
|
||
const userStatus = scenarioRow?.status?.toLowerCase();
|
||
const combined = buildStatusSituationMessage(userStatus, userSituation, careerName);
|
||
|
||
const intro = `
|
||
Hi! ${combined}<br/>
|
||
${goalsText ? `Your goals include:<br />${goalsText.split(/^\d+\.\s+/gm).filter(Boolean).map(g => `• ${g.trim()}`).join("<br />")}<br/>` : ""}
|
||
${riskLevel ? `Note: This role has a <strong>${riskLevel}</strong> automation risk over the next 10 years. ${riskReasoning}<br/>` : ""}
|
||
I'm here to support you with personalized coaching. What would you like to focus on today?`;
|
||
return { role: "assistant", content: intro };
|
||
}
|
||
|
||
/* ------------ shared AI caller ------------- */
|
||
async function callAi(updatedHistory, opts = {}) {
|
||
setLoading(true);
|
||
try {
|
||
const payload = {
|
||
userProfile,
|
||
financialProfile,
|
||
scenarioRow,
|
||
collegeProfile,
|
||
chatHistory: updatedHistory.slice(-10),
|
||
...opts
|
||
};
|
||
const res = await authFetch("/api/premium/ai/chat", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await res.json(); // might only have .error
|
||
const replyRaw = data?.reply ?? ""; // always a string
|
||
const riskData = data?.aiRisk;
|
||
const createdMilestones = data?.createdMilestones ?? [];
|
||
|
||
// guard – empty or non-string → generic apology
|
||
const safeReply = typeof replyRaw === "string" && replyRaw.trim()
|
||
? replyRaw
|
||
: "Sorry, something went wrong on the server.";
|
||
|
||
// If GPT accidentally returned raw JSON, hide it from user
|
||
const isJson = safeReply.trim().startsWith("{") || safeReply.trim().startsWith("[");
|
||
const friendlyReply = isJson
|
||
? "✅ Got it! I added new milestones to your plan. Check your Milestones tab."
|
||
: safeReply;
|
||
|
||
setMessages((prev) => [...prev, { role: "assistant", content: friendlyReply }]);
|
||
|
||
if (riskData && onAiRiskFetched) onAiRiskFetched(riskData);
|
||
if (createdMilestones.length && typeof onMilestonesCreated === 'function') {
|
||
onMilestonesCreated(); // no arg needed – just refetch
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
setMessages((prev) => [...prev, { role: "assistant", content: "Sorry, something went wrong." }]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
/* ------------ normal send ------------- */
|
||
function handleSubmit(e) {
|
||
e.preventDefault();
|
||
if (!input.trim() || loading) return;
|
||
const userMsg = { role: "user", content: input.trim() };
|
||
const newHistory = [...messages, userMsg];
|
||
setMessages(newHistory);
|
||
setInput("");
|
||
callAi(newHistory);
|
||
}
|
||
|
||
/* ------------ quick-action buttons ------------- */
|
||
function triggerQuickAction(type) {
|
||
if (loading) return; // debounce
|
||
|
||
/* shared context */
|
||
const careerName = scenarioRow?.career_name || "your role";
|
||
const goalsText = scenarioRow?.career_goals || "";
|
||
const skillsText = userProfile?.key_skills || "";
|
||
const avoidList =
|
||
[...messages].reverse()
|
||
.find(m => m.role === "system" && m.content.startsWith("[CURRENT MILESTONES]"))
|
||
?.content.split("\n").slice(2).join("\n") || "";
|
||
|
||
/* 1) Mock-Interview (special flow) */
|
||
if (type === "interview") {
|
||
const desc = scenarioRow?.job_description || "";
|
||
const hiddenSystem = { role:"system", content: buildInterviewPrompt(careerName, desc) };
|
||
const note = { role:"assistant", content:`Starting mock interview on **${careerName}**. Answer each question and I'll give feedback!` };
|
||
const updated = [...messages, note, hiddenSystem];
|
||
setMessages([...messages, note]);
|
||
callAi(updated);
|
||
return;
|
||
}
|
||
|
||
/* 2) All other quick actions share the same pattern */
|
||
const note = {
|
||
role : "assistant",
|
||
content: {
|
||
networking: "Sure! Let me create a Networking roadmap for you…",
|
||
jobSearch : "Sure! Let me create a Job-Search roadmap for you…",
|
||
aiGrowth : "Sure! Let’s map out how you can *partner* with AI in this career…"
|
||
}[type] || "OK!"
|
||
};
|
||
|
||
const hiddenSystem = {
|
||
role : "system",
|
||
content: QUICK_PROMPTS[type]({
|
||
careerName, goalsText, skillsText, avoidList
|
||
})
|
||
};
|
||
|
||
const updated = [...messages, note, hiddenSystem];
|
||
setMessages([...messages, note]);
|
||
const needsContext = ["networking", "jobSearch", "aiGrowth"].includes(type);
|
||
callAi(updated, {forceContext: needsContext});
|
||
}
|
||
|
||
|
||
/* ------------ render ------------- */
|
||
return (
|
||
<div className="border rounded-lg shadow bg-white p-6 mb-6">
|
||
<h2 className="text-2xl font-semibold mb-4">Career Coach</h2>
|
||
|
||
{/* Quick-action bar */}
|
||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||
<button
|
||
onClick={() => triggerQuickAction("networking")}
|
||
disabled={loading}
|
||
className="bg-emerald-600 hover:bg-emerald-700 text-white px-3 py-1 rounded"
|
||
>
|
||
Networking Plan
|
||
</button>
|
||
<button
|
||
onClick={() => triggerQuickAction("jobSearch")}
|
||
disabled={loading}
|
||
className="bg-indigo-600 hover:bg-indigo-700 text-white px-3 py-1 rounded"
|
||
>
|
||
Job-Search Plan
|
||
</button>
|
||
<button
|
||
onClick={() => triggerQuickAction("interview")}
|
||
disabled={loading}
|
||
className="bg-orange-600 hover:bg-orange-700 text-white px-3 py-1 rounded"
|
||
>
|
||
Interview Help
|
||
</button>
|
||
<button
|
||
onClick={() => triggerQuickAction("aiGrowth")}
|
||
disabled={loading}
|
||
className="bg-teal-600 hover:bg-teal-700 text-white px-3 py-1 rounded"
|
||
>
|
||
Grow Career with AI
|
||
</button>
|
||
{/* pushes Edit Goals to the far right */}
|
||
<div className="flex-grow"></div>
|
||
|
||
<button
|
||
onClick={() => {
|
||
// always load the latest goals before showing the modal
|
||
setDraftGoals(scenarioRow?.career_goals || "");
|
||
setShowGoals(true);
|
||
}}
|
||
className="border border-gray-300 bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-300"
|
||
>
|
||
Edit Goals
|
||
</button>
|
||
</div>
|
||
|
||
|
||
{/* Chat area */}
|
||
<div
|
||
ref={chatRef}
|
||
className="overflow-y-auto border rounded mb-4 space-y-2"
|
||
style={{ maxHeight: 320, minHeight: 200, padding: "1rem" }}
|
||
>
|
||
{messages.map((m, i) => (
|
||
<div
|
||
key={i}
|
||
className={`rounded p-2 ${
|
||
m.role === "user"
|
||
? "bg-blue-100 text-blue-800 self-end"
|
||
: "bg-gray-200 text-gray-800 self-start"
|
||
}`}
|
||
dangerouslySetInnerHTML={{ __html: m.content.replace(/\n/g, "<br/>") }}
|
||
/>
|
||
))}
|
||
{loading && <div className="text-sm italic text-gray-500">Coach is typing…</div>}
|
||
</div>
|
||
|
||
{/* AI risk banner */}
|
||
{aiRisk?.riskLevel && (
|
||
<div className="p-2 my-2 bg-yellow-100 text-yellow-900 rounded">
|
||
<strong>Automation Risk:</strong> {aiRisk.riskLevel}
|
||
<br />
|
||
<em>{aiRisk.reasoning}</em>
|
||
</div>
|
||
)}
|
||
|
||
{/* Input */}
|
||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||
<input
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
disabled={loading}
|
||
placeholder="Ask your Career Coach…"
|
||
className="flex-grow border rounded py-2 px-3"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className={`rounded px-4 py-2 ${
|
||
loading
|
||
? "bg-gray-300 text-gray-600 cursor-not-allowed"
|
||
: "bg-blue-500 hover:bg-blue-600 text-white"
|
||
}`}
|
||
>
|
||
Send
|
||
</button>
|
||
</form>
|
||
|
||
{showGoals && (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow">
|
||
<h3 className="text-xl font-semibold mb-3">Edit Your Career Goals</h3>
|
||
|
||
<textarea
|
||
value={draftGoals}
|
||
onChange={(e) => setDraftGoals(e.target.value)}
|
||
rows={5}
|
||
className="w-full border rounded p-2"
|
||
placeholder="Describe your short- and long-term goals…"
|
||
/>
|
||
|
||
<div className="mt-4 flex justify-end gap-2">
|
||
<button
|
||
className="px-4 py-2 rounded border"
|
||
onClick={() => setShowGoals(false)}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
|
||
disabled={saving}
|
||
onClick={async () => {
|
||
setSaving(true);
|
||
await authFetch(
|
||
`/api/premium/career-profile/${careerProfileId}/goals`,
|
||
{
|
||
method : 'PUT',
|
||
headers: { 'Content-Type':'application/json' },
|
||
body : JSON.stringify({ career_goals: draftGoals })
|
||
}
|
||
);
|
||
// lift new goals into parent state so Jess sees them
|
||
setScenarioRow((p) => ({ ...p, career_goals: draftGoals }));
|
||
setSaving(false);
|
||
setShowGoals(false);
|
||
}}
|
||
>
|
||
{saving ? 'Saving…' : 'Save'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
);
|
||
}
|