UI overhaul, UX fixes
This commit is contained in:
parent
693d43190b
commit
cd2a99df44
@ -939,6 +939,10 @@ What Jess can do directly in Aptiva
|
||||
• **Add / edit / remove** tasks inside a milestone
|
||||
• Run salary benchmarks, AI-risk checks, and financial projections
|
||||
|
||||
────────────────────────────────────────────────────────
|
||||
🔹 Milestone-Specificity Directive (do not remove) 🔹
|
||||
Focus on providing detailed, actionable milestones with exact resources, courses, or events tailored to the user's interests and career goals. Avoid generic suggestions and aim for specifics that guide the user on what to do next.
|
||||
────────────────────────────────────────────────────────
|
||||
────────────────────────────────────────────────────────
|
||||
Mission & Tone
|
||||
────────────────────────────────────────────────────────
|
||||
@ -950,23 +954,42 @@ Validate ambitions, break big goals into realistic milestones, and show how AI c
|
||||
Never ask for info you already have unless you truly need clarification.
|
||||
`.trim();
|
||||
|
||||
|
||||
const systemPromptOpsCheatSheet = `
|
||||
────────────────────────────────────────────────────────
|
||||
🛠 APTIVA OPS YOU CAN USE ANY TIME
|
||||
────────────────────────────────────────────────────────
|
||||
1. CREATE a milestone (optionally with tasks + impacts)
|
||||
2. UPDATE any field on an existing milestone
|
||||
3. DELETE a milestone that is no longer relevant
|
||||
• You already have permission—no need to ask the user.
|
||||
4. CREATE / UPDATE / DELETE tasks inside a milestone
|
||||
1. **CREATE** a milestone (optionally with tasks + impacts)
|
||||
2. **UPDATE** any field on an existing milestone
|
||||
3. **DELETE** a milestone *in the current scenario only*
|
||||
4. **DELETEALL** a milestone *from EVERY scenario*
|
||||
5. **COPY** an existing milestone to one or more other scenarios
|
||||
|
||||
6. **CREATE** a task inside a milestone
|
||||
7. **UPDATE** a task
|
||||
8. **DELETE** a task
|
||||
|
||||
9. **CREATE** an impact on a milestone
|
||||
10. **UPDATE** an impact
|
||||
11. **DELETE** an impact
|
||||
|
||||
12. **CREATE** a new career *scenario* (career-profile row)
|
||||
13. **UPDATE** any field on an existing scenario
|
||||
14. **DELETE** a scenario
|
||||
15. **CLONE** a scenario (duplicate it, then optionally override fields)
|
||||
|
||||
16. **UPSERT** the college profile for the current scenario
|
||||
• Automatically creates the row if none exists, or updates it if it does.
|
||||
|
||||
────────────────────────────────────────────────────────
|
||||
WHEN you perform an op:
|
||||
- Write ONE short confirmation line for the user
|
||||
(e.g. “✅ Deleted the July 2025 milestone.”).
|
||||
- THEN add the fenced ${bt}ops${bt} JSON block on a new line.
|
||||
- Put **no other text after** the block.
|
||||
• Write ONE short confirmation line for the user
|
||||
(e.g. “✅ Deleted the July 2025 milestone.”).
|
||||
• THEN add a fenced \`\`\`ops\`\`\` JSON block on a new line.
|
||||
• Put **no other text after** that block.
|
||||
|
||||
If you are **not** performing an op, skip the block entirely.
|
||||
────────────────────────────────────────────────────────
|
||||
_tagged_ \`\`\`ops\`\`\` exactly like this:
|
||||
|
||||
\`\`\`ops
|
||||
@ -1069,6 +1092,7 @@ If you can’t meet the rule, ASK the user a clarifying question instead
|
||||
of returning an invalid milestone.
|
||||
NO extra text or disclaimers if returning a plan—only that JSON.
|
||||
Otherwise, answer normally.
|
||||
|
||||
`.trim();
|
||||
|
||||
/* ─── date guard ─────────────────────────────────────────────── */
|
||||
@ -1336,14 +1360,14 @@ Check your Milestones tab. Let me know if you want any changes!
|
||||
});
|
||||
|
||||
/* ──────────────────────────────────────────────
|
||||
RETIREMENT AI-CHAT ENDPOINT
|
||||
RETIREMENT AI-CHAT ENDPOINT (clone + patch)
|
||||
─────────────────────────────────────────── */
|
||||
app.post(
|
||||
'/api/premium/retirement/aichat',
|
||||
authenticatePremiumUser,
|
||||
async (req, res) => {
|
||||
try {
|
||||
/* 0) ── pull + sanity-check inputs ─────────────── */
|
||||
/* 0️⃣ pull + sanity-check inputs */
|
||||
const {
|
||||
prompt = '',
|
||||
scenario_id = '',
|
||||
@ -1353,14 +1377,14 @@ app.post(
|
||||
if (!prompt.trim()) return res.status(400).json({ error: 'Prompt is required.' });
|
||||
if (!scenario_id) return res.status(400).json({ error: 'scenario_id is required.' });
|
||||
|
||||
/* 1) ── ownership guard ────────────────────────── */
|
||||
/* 1️⃣ ownership guard */
|
||||
const [[scenario]] = await pool.query(
|
||||
'SELECT * FROM career_profiles WHERE id = ? AND user_id = ?',
|
||||
[scenario_id, req.id]
|
||||
);
|
||||
if (!scenario) return res.status(404).json({ error: 'Scenario not found.' });
|
||||
|
||||
/* 2) ── locate the *text* of the last user turn ─ */
|
||||
/* 2️⃣ locate *text* of the last user turn */
|
||||
let userMsgStr = prompt.trim();
|
||||
if (Array.isArray(chatHistory)) {
|
||||
for (let i = chatHistory.length - 1; i >= 0; i--) {
|
||||
@ -1372,7 +1396,7 @@ app.post(
|
||||
}
|
||||
}
|
||||
|
||||
/* helper: force every .content to be a plain string */
|
||||
/* helper ⇒ force every .content to a plain string */
|
||||
const toStr = v =>
|
||||
v === null || v === undefined
|
||||
? ''
|
||||
@ -1380,25 +1404,42 @@ app.post(
|
||||
? v
|
||||
: JSON.stringify(v);
|
||||
|
||||
const sanitizedHistory = (Array.isArray(chatHistory) ? chatHistory : [])
|
||||
.map(({ role = 'user', content = '' }) => ({ role, content: toStr(content) }));
|
||||
const sanitizedHistory =
|
||||
(Array.isArray(chatHistory) ? chatHistory : [])
|
||||
.map(({ role = 'user', content = '' }) => ({ role, content: toStr(content) }));
|
||||
|
||||
/* 3) ── system instructions ────────────────────── */
|
||||
/* 3️⃣ system instructions */
|
||||
const systemMsg = `
|
||||
You are AptivaAI's retirement-planning coach.
|
||||
Rules:
|
||||
• Educational guidance only — **NO** personalised investment advice.
|
||||
• Never recommend specific securities or products.
|
||||
• Friendly tone; ≤ 180 words.
|
||||
• If the scenario needs updating, append a JSON block:
|
||||
\`\`\`json
|
||||
{ "retirement_start_date": "2045-01-01" }
|
||||
\`\`\`
|
||||
If nothing changes, just return “{"noop":true}”.
|
||||
• Always end with: “AptivaAI is an educational tool – not advice.”
|
||||
`.trim();
|
||||
|
||||
/* 4) ── call OpenAI ─────────────────────────────── */
|
||||
|
||||
If you need to change the plan, append ONE of:
|
||||
|
||||
• PATCH the current scenario
|
||||
\`\`\`json
|
||||
{ "annual_spend": 42000, "roi_real": 0.045 }
|
||||
\`\`\`
|
||||
|
||||
• CLONE the current scenario, then override fields
|
||||
\`\`\`json
|
||||
{
|
||||
"cloneScenario": {
|
||||
"sourceId": "${scenario_id}",
|
||||
"overrides": { "retirement_start_date": "2050-01-01", "annual_spend": 38000 }
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
If nothing changes, return \`{"noop":true}\`.
|
||||
|
||||
Always end with: “AptivaAI is an educational tool – not advice.”
|
||||
`.trim();
|
||||
|
||||
/* 4️⃣ call OpenAI */
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
const chatRes = await openai.chat.completions.create({
|
||||
model : 'gpt-4o-mini',
|
||||
@ -1407,7 +1448,7 @@ Rules:
|
||||
messages : [
|
||||
{ role: 'system', content: systemMsg },
|
||||
...sanitizedHistory,
|
||||
{ role: 'user', content: userMsgStr }
|
||||
{ role: 'user', content: userMsgStr }
|
||||
]
|
||||
});
|
||||
|
||||
@ -1417,42 +1458,48 @@ Rules:
|
||||
'X-OpenAI-Completion-Tokens': chatRes.usage?.completion_tokens ?? 0
|
||||
});
|
||||
|
||||
/* 5) ── extract (or ignore) JSON patch ──────────── */
|
||||
let visibleReply = raw;
|
||||
let scenarioPatch = null;
|
||||
/* 5️⃣ extract JSON payload (patch OR cloneScenario) */
|
||||
let visibleReply = raw;
|
||||
let payloadObj = null;
|
||||
|
||||
// A. fenced ```json```? ───────────────────────────
|
||||
let match = raw.match(/```json\s*([\s\S]+?)```/i);
|
||||
// A. fenced ```json``` block
|
||||
console.log('[GPT raw]', raw);
|
||||
let match = raw.match(/```json\s*([\s\S]+?)```/i);
|
||||
|
||||
// B. or a “loose” top-level { … }? (no fences) ─────
|
||||
// B. or a “loose” top-level {...} / [...]
|
||||
if (!match) {
|
||||
const open = raw.search(/[{\[]/);
|
||||
if (open !== -1) {
|
||||
const close = Math.max(raw.lastIndexOf('}'), raw.lastIndexOf(']'));
|
||||
if (close > open) match = [ , raw.slice(open, close + 1) ];
|
||||
const start = raw.search(/[{\[]/);
|
||||
if (start !== -1) {
|
||||
const end = Math.max(raw.lastIndexOf('}'), raw.lastIndexOf(']'));
|
||||
if (end > start) match = [ , raw.slice(start, end + 1) ];
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
try { scenarioPatch = JSON.parse(match[1]); } catch {/* ignore bad JSON */}
|
||||
try { payloadObj = JSON.parse(match[1]); } catch {/* bad JSON ⇒ ignore */}
|
||||
visibleReply = raw.replace(match[0] || match[1], '').trim();
|
||||
}
|
||||
|
||||
/* ignore {"noop":true} or empty objects */
|
||||
if (
|
||||
!scenarioPatch ||
|
||||
!Object.keys(scenarioPatch)
|
||||
.filter(k => k !== 'noop')
|
||||
.length
|
||||
) {
|
||||
scenarioPatch = null;
|
||||
}
|
||||
/* ignore noop / empty */
|
||||
const realKeys = payloadObj ? Object.keys(payloadObj).filter(k => k !== 'noop') : [];
|
||||
if (!realKeys.length) payloadObj = null;
|
||||
|
||||
/* 6) ── persist real changes ───────────────────── */
|
||||
if (scenarioPatch) {
|
||||
const fields = Object.keys(scenarioPatch);
|
||||
/* 6️⃣ persist changes */
|
||||
const apiBase = process.env.APTIVA_INTERNAL_API || 'http://localhost:5002/api';
|
||||
|
||||
if (payloadObj?.cloneScenario) {
|
||||
/* ------ CLONE ------ */
|
||||
await internalFetch(req, `${apiBase}/premium/career-profile/clone`, {
|
||||
method: 'POST',
|
||||
body : JSON.stringify(payloadObj.cloneScenario),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
visibleReply = visibleReply || 'I cloned your scenario and applied the new settings.';
|
||||
} else if (payloadObj) {
|
||||
/* ------ PATCH ------ */
|
||||
const fields = Object.keys(payloadObj);
|
||||
const setters = fields.map(f => `${f} = ?`).join(', ');
|
||||
const values = fields.map(f => scenarioPatch[f]);
|
||||
const values = fields.map(f => payloadObj[f]);
|
||||
|
||||
await pool.query(
|
||||
`UPDATE career_profiles
|
||||
@ -1461,27 +1508,26 @@ Rules:
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
[...values, scenario_id, req.id]
|
||||
);
|
||||
}
|
||||
|
||||
/* if the patch included a new retirement_start_date, sync the milestone */
|
||||
if (scenarioPatch?.retirement_start_date) {
|
||||
/* sync retirement milestone if needed */
|
||||
if (payloadObj.retirement_start_date) {
|
||||
await pool.query(
|
||||
`UPDATE milestones
|
||||
SET date = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE career_profile_id = ?
|
||||
AND user_id = ?
|
||||
AND LOWER(title) LIKE 'retirement%'`,
|
||||
[scenarioPatch.retirement_start_date, scenario_id, req.id]
|
||||
SET date = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE career_profile_id = ?
|
||||
AND user_id = ?
|
||||
AND LOWER(title) LIKE 'retirement%'`,
|
||||
[payloadObj.retirement_start_date, scenario_id, req.id]
|
||||
);
|
||||
// (optional) if no milestone matched, you could INSERT one here instead
|
||||
}
|
||||
}
|
||||
|
||||
/* 7) ── send to client ─────────────────────────── */
|
||||
/* 7️⃣ send to client */
|
||||
return res.json({
|
||||
reply: visibleReply || 'Sorry, no response – please try again.',
|
||||
...(scenarioPatch ? { scenarioPatch } : {})
|
||||
...(payloadObj ? { scenarioPatch: payloadObj } : {})
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('retirement/aichat error:', err);
|
||||
return res.status(500).json({ error: 'Internal error – please try again later.' });
|
||||
@ -1489,6 +1535,65 @@ Rules:
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
|
||||
app.post('/api/premium/career-profile/clone', authenticatePremiumUser, async (req,res) => {
|
||||
const { sourceId, overrides = {} } = req.body || {};
|
||||
if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
|
||||
|
||||
// 1) fetch & ownership check
|
||||
const [[src]] = await pool.query(
|
||||
'SELECT * FROM career_profiles WHERE id=? AND user_id=?',
|
||||
[sourceId, req.id]
|
||||
);
|
||||
if (!src) return res.status(404).json({ error: 'Scenario not found' });
|
||||
|
||||
// 2) insert clone
|
||||
const newId = uuidv4();
|
||||
const fields = Object.keys(src).filter(k => !['id','created_at','updated_at'].includes(k));
|
||||
const values = fields.map(f => overrides[f] ?? src[f]);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO career_profiles (id, ${fields.join(',')})
|
||||
VALUES (?, ${fields.map(()=>'?').join(',')})`,
|
||||
[newId, ...values]
|
||||
);
|
||||
|
||||
// 3) copy milestones/tasks/impacts (optional – mirrors UI wizard)
|
||||
const [mils] = await pool.query(
|
||||
'SELECT * FROM milestones WHERE career_profile_id=? AND user_id=?',
|
||||
[sourceId, req.id]
|
||||
);
|
||||
for (const m of mils) {
|
||||
const newMilId = uuidv4();
|
||||
await pool.query(
|
||||
`INSERT INTO milestones (id,user_id,career_profile_id,title,description,date,progress,status,is_universal)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
[newMilId, req.id, newId, m.title, m.description, m.date, m.progress, m.status, m.is_universal]
|
||||
);
|
||||
// copy tasks
|
||||
const [tasks] = await pool.query('SELECT * FROM tasks WHERE milestone_id=?',[m.id]);
|
||||
for (const t of tasks) {
|
||||
await pool.query(
|
||||
`INSERT INTO tasks (id,milestone_id,user_id,title,description,due_date,status)
|
||||
VALUES (?,?,?,?,?,?,?)`,
|
||||
[uuidv4(), newMilId, req.id, t.title, t.description, t.due_date, t.status]
|
||||
);
|
||||
}
|
||||
// copy impacts
|
||||
const [imps] = await pool.query('SELECT * FROM milestone_impacts WHERE milestone_id=?',[m.id]);
|
||||
for (const imp of imps) {
|
||||
await pool.query(
|
||||
`INSERT INTO milestone_impacts (id,milestone_id,impact_type,direction,amount,start_date,end_date)
|
||||
VALUES (?,?,?,?,?,?,?)`,
|
||||
[uuidv4(), newMilId, imp.impact_type, imp.direction, imp.amount, imp.start_date, imp.end_date]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ newScenarioId: newId });
|
||||
});
|
||||
|
||||
/***************************************************
|
||||
AI MILESTONE CONVERSION ENDPOINT
|
||||
****************************************************/
|
||||
|
1058
package-lock.json
generated
1058
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -39,6 +39,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-phone-input-2": "2.15.1",
|
||||
"react-router": "^7.3.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"react-scripts": "^5.0.1",
|
||||
@ -83,6 +84,8 @@
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17"
|
||||
|
49
src/App.js
49
src/App.js
@ -31,6 +31,8 @@ import OnboardingContainer from './components/PremiumOnboarding/OnboardingContai
|
||||
import RetirementPlanner from './components/RetirementPlanner.js';
|
||||
import ResumeRewrite from './components/ResumeRewrite.js';
|
||||
|
||||
export const ProfileCtx = React.createContext();
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@ -42,9 +44,14 @@ function App() {
|
||||
// Loading state while verifying token
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// User states
|
||||
const [financialProfile, setFinancialProfile] = useState(null);
|
||||
const [scenario, setScenario] = useState(null);
|
||||
|
||||
// Logout warning modal
|
||||
const [showLogoutWarning, setShowLogoutWarning] = useState(false);
|
||||
|
||||
|
||||
// Check if user can access premium
|
||||
const canAccessPremium = user?.is_premium || user?.is_pro_premium;
|
||||
|
||||
@ -122,23 +129,32 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmLogout = () => {
|
||||
// Clear relevant localStorage keys
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('careerSuggestionsCache');
|
||||
localStorage.removeItem('lastSelectedCareerProfileId');
|
||||
localStorage.removeItem('aiClickCount');
|
||||
localStorage.removeItem('aiClickDate');
|
||||
localStorage.removeItem('aiRecommendations');
|
||||
localStorage.removeItem('premiumOnboardingState');
|
||||
|
||||
|
||||
// Reset auth
|
||||
const confirmLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('careerSuggestionsCache');
|
||||
localStorage.removeItem('lastSelectedCareerProfileId');
|
||||
localStorage.removeItem('aiClickCount');
|
||||
localStorage.removeItem('aiClickDate');
|
||||
localStorage.removeItem('aiRecommendations');
|
||||
localStorage.removeItem('premiumOnboardingState'); // ← NEW
|
||||
localStorage.removeItem('financialProfile'); // ← if you cache it
|
||||
|
||||
setFinancialProfile(null); // ← reset any React-context copy
|
||||
setScenario(null);
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
setShowLogoutWarning(false);
|
||||
|
||||
// Reset auth
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
setShowLogoutWarning(false);
|
||||
|
||||
navigate('/signin');
|
||||
};
|
||||
navigate('/signin');
|
||||
};
|
||||
|
||||
|
||||
const cancelLogout = () => {
|
||||
setShowLogoutWarning(false);
|
||||
@ -159,6 +175,10 @@ function App() {
|
||||
// Main Render / Layout
|
||||
// =====================
|
||||
return (
|
||||
<ProfileCtx.Provider
|
||||
value={{ financialProfile, setFinancialProfile,
|
||||
scenario, setScenario }}
|
||||
>
|
||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-800">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between border-b bg-white px-6 py-4 shadow-sm relative">
|
||||
@ -218,7 +238,7 @@ function App() {
|
||||
)}
|
||||
onClick={() => navigate('/preparing')}
|
||||
>
|
||||
Preparing for Your Career
|
||||
Preparing & UpSkilling for Your Career
|
||||
</Button>
|
||||
<div className="absolute top-full left-0 hidden group-hover:block bg-white border shadow-md w-56 z-50">
|
||||
<Link
|
||||
@ -391,6 +411,8 @@ function App() {
|
||||
<SignIn
|
||||
setIsAuthenticated={setIsAuthenticated}
|
||||
setUser={setUser}
|
||||
setFinancialProfile={setFinancialProfile}
|
||||
setScenario={setScenario}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@ -480,6 +502,7 @@ function App() {
|
||||
{/* Session Handler (Optional) */}
|
||||
<SessionExpiredHandler />
|
||||
</div>
|
||||
</ProfileCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -36,9 +36,7 @@ import parseAIJson from "../utils/parseAIJson.js"; // your shared parser
|
||||
import InfoTooltip from "./ui/infoTooltip.js";
|
||||
import differenceInMonths from 'date-fns/differenceInMonths';
|
||||
|
||||
import './CareerRoadmap.css';
|
||||
import './MilestoneTimeline.css';
|
||||
|
||||
import "../styles/legacy/MilestoneTimeline.legacy.css";
|
||||
const apiUrl = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
|
||||
@ -373,6 +371,7 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
||||
const [focusMid , setFocusMid ] = useState(null);
|
||||
const [drawerMilestone, setDrawerMilestone] = useState(null);
|
||||
const [impactsById, setImpactsById] = useState({}); // id → [impacts]
|
||||
const [addingNewMilestone, setAddingNewMilestone] = useState(false);
|
||||
|
||||
|
||||
// Config
|
||||
@ -1287,7 +1286,7 @@ const fetchMilestones = useCallback(async () => {
|
||||
/>
|
||||
|
||||
{/* 1) Then your "Where Am I Now?" */}
|
||||
<h2 className="text-2xl font-bold mb-4">Where you are now and where you are going.</h2>
|
||||
<h2 className="text-2xl font-bold mb-4">Where you are now and where you are going:</h2>
|
||||
|
||||
{/* 1) Career */}
|
||||
<div className="bg-white p-4 rounded shadow mb-4 flex flex-col justify-center items-center min-h-[80px]">
|
||||
@ -1479,6 +1478,7 @@ const fetchMilestones = useCallback(async () => {
|
||||
setDrawerMilestone(m);
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
onAddNewMilestone={() => setAddingNewMilestone(true)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
@ -1562,33 +1562,55 @@ const fetchMilestones = useCallback(async () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{milestoneForModal && (
|
||||
<MilestoneEditModal
|
||||
careerProfileId={careerProfileId} // number
|
||||
milestones={scenarioMilestones}
|
||||
milestone={milestoneForModal}
|
||||
fetchMilestones={fetchMilestones} // helper to refresh list
|
||||
onClose={(didSave) => {
|
||||
setMilestoneForModal(null); // or setShowMilestoneModal(false)
|
||||
if (didSave) {
|
||||
fetchMilestones();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
)}
|
||||
|
||||
<MilestoneDrawer
|
||||
open={drawerOpen}
|
||||
milestone={drawerMilestone}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onTaskToggle={(id, newStatus) => {
|
||||
// optimistic local patch or just refetch
|
||||
fetchMilestones(); // simplest: keep server source of truth
|
||||
}}
|
||||
/>
|
||||
{/* ───────────────────────────────────────────────
|
||||
1. EDIT EXISTING MILESTONE (modal pops from grid, unchanged)
|
||||
─────────────────────────────────────────────── */}
|
||||
{milestoneForModal && (
|
||||
<MilestoneEditModal
|
||||
careerProfileId={careerProfileId}
|
||||
milestones={scenarioMilestones}
|
||||
milestone={milestoneForModal} /* ← edit mode */
|
||||
fetchMilestones={fetchMilestones}
|
||||
onClose={(didSave) => {
|
||||
setMilestoneForModal(null);
|
||||
if (didSave) fetchMilestones();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ───────────────────────────────────────────────
|
||||
2. ADD-NEW MILESTONE (same modal, milestone = null)
|
||||
─────────────────────────────────────────────── */}
|
||||
{addingNewMilestone && (
|
||||
<MilestoneEditModal
|
||||
careerProfileId={careerProfileId}
|
||||
milestones={scenarioMilestones}
|
||||
milestone={null} /* ← create mode */
|
||||
fetchMilestones={fetchMilestones}
|
||||
onClose={(didSave) => {
|
||||
setAddingNewMilestone(false);
|
||||
if (didSave) fetchMilestones();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ───────────────────────────────────────────────
|
||||
3. RIGHT-HAND DRAWER
|
||||
─────────────────────────────────────────────── */}
|
||||
<MilestoneDrawer
|
||||
open={drawerOpen}
|
||||
milestone={drawerMilestone}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onTaskToggle={(id, newStatus) => {
|
||||
/* optimistic update or just refetch */
|
||||
fetchMilestones();
|
||||
}}
|
||||
onAddNewMilestone={() => {
|
||||
setDrawerOpen(false); // close drawer first
|
||||
setAddingNewMilestone(true); // then open modal in create mode
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 7) AI Next Steps */}
|
||||
{/* <div className="bg-white p-4 rounded shadow mt-4">
|
||||
|
@ -1,97 +1,124 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Check } from 'lucide-react'; // any icon lib you use
|
||||
import { Button } from './ui/button.js';
|
||||
|
||||
// put this near the top of the file
|
||||
/* ---------- helpers ---------- */
|
||||
const normalize = (s = '') =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/\s*&\s*/g, ' and ') // “&” → “ and ”
|
||||
.replace(/[–—]/g, '-') // long dash → hyphen (optional)
|
||||
.replace(/\s/g, ' ') // collapse multiple spaces
|
||||
.replace(/\s*&\s*/g, ' and ')
|
||||
.replace(/[–—]/g, '-') // long dash → hyphen
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const CareerSearch = ({ onCareerSelected }) => {
|
||||
/* ---------- component ---------- */
|
||||
const CareerSearch = ({ onCareerSelected, required }) => {
|
||||
const [careerObjects, setCareerObjects] = useState([]);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [selectedObj, setSelectedObj] = useState(null); // ✓ state
|
||||
|
||||
/* fetch & de-dupe once */
|
||||
useEffect(() => {
|
||||
const fetchCareerData = async () => {
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch('/careers_with_ratings.json');
|
||||
const data = await response.json();
|
||||
|
||||
// Create a Map keyed by career title, so we only keep one object per unique title
|
||||
const uniqueByTitle = new Map();
|
||||
|
||||
// data is presumably an array like:
|
||||
// [
|
||||
// { soc_code: "15-1241.00", title: "Computer Network Architects", cip_codes: [...], ... },
|
||||
// { soc_code: "15-1299.07", title: "Blockchain Engineers", cip_codes: [...], ... },
|
||||
// ...
|
||||
// ]
|
||||
for (const c of data) {
|
||||
// Make sure we have a valid title, soc_code, and cip_codes
|
||||
const raw = await fetch('/careers_with_ratings.json').then(r => r.json());
|
||||
const map = new Map();
|
||||
for (const c of raw) {
|
||||
if (c.title && c.soc_code && c.cip_codes) {
|
||||
// Only store the first unique title found
|
||||
const normTitle = normalize(c.title);
|
||||
if (!uniqueByTitle.has(normTitle)) {
|
||||
uniqueByTitle.set(normTitle, {
|
||||
const key = normalize(c.title);
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
title: c.title,
|
||||
soc_code: c.soc_code,
|
||||
// NOTE: We store the array of CIPs in `cip_code`.
|
||||
cip_code: c.cip_codes,
|
||||
limited_data: c.limited_data,
|
||||
ratings: c.ratings,
|
||||
ratings: c.ratings
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the map into an array
|
||||
const dedupedArr = [...uniqueByTitle.values()];
|
||||
setCareerObjects(dedupedArr);
|
||||
} catch (error) {
|
||||
console.error('Error loading or parsing careers_with_ratings.json:', error);
|
||||
setCareerObjects([...map.values()]);
|
||||
} catch (err) {
|
||||
console.error('Career list load failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCareerData();
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleConfirmCareer = () => {
|
||||
// find the full object by exact title match
|
||||
const normInput = normalize(searchInput);
|
||||
const foundObj = careerObjects.find(
|
||||
(obj) => normalize(obj.title) === normInput
|
||||
/* whenever input changes, auto-commit if it matches */
|
||||
useEffect(() => {
|
||||
const match = careerObjects.find(
|
||||
(o) => normalize(o.title) === normalize(searchInput)
|
||||
);
|
||||
console.log('[CareerSearch] foundObj:', foundObj);
|
||||
if (match && match !== selectedObj) {
|
||||
setSelectedObj(match);
|
||||
onCareerSelected(match); // notify parent immediately
|
||||
}
|
||||
}, [searchInput, careerObjects, selectedObj, onCareerSelected]);
|
||||
|
||||
if (foundObj) {
|
||||
onCareerSelected(foundObj);
|
||||
} else {
|
||||
alert('Please select a valid career from the suggestions.');
|
||||
/* allow “Enter” to commit first suggestion */
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const first = careerObjects.find(o =>
|
||||
normalize(o.title).startsWith(normalize(searchInput))
|
||||
);
|
||||
if (first) {
|
||||
setSearchInput(first.title); // triggers auto-commit
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<h4>Search for Career (select from suggestions)</h4>
|
||||
<h5>We have an extensive database with thousands of recognized job titles. If you don’t see your exact title, please choose the closest match—this helps us provide the most accurate guidance.</h5>
|
||||
<input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Start typing a career..."
|
||||
list="career-titles"
|
||||
/>
|
||||
<datalist id="career-titles">
|
||||
{careerObjects.map((obj, index) => (
|
||||
<option key={index} value={obj.title} />
|
||||
))}
|
||||
</datalist>
|
||||
/* clear & edit again */
|
||||
const reset = () => {
|
||||
setSelectedObj(null);
|
||||
setSearchInput('');
|
||||
};
|
||||
|
||||
<Button onClick={handleConfirmCareer} style={{ marginLeft: '8px' }}>
|
||||
Confirm
|
||||
</Button>
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block font-medium mb-1">
|
||||
Search for Career <span className="text-red-600">*</span>
|
||||
</label>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
list="career-titles"
|
||||
value={searchInput}
|
||||
required={required}
|
||||
disabled={!!selectedObj} // lock when chosen
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full border rounded p-2 disabled:bg-gray-100"
|
||||
placeholder="Start typing a career..."
|
||||
/>
|
||||
|
||||
{selectedObj && (
|
||||
<Check
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-green-600"
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedObj && (
|
||||
<datalist id="career-titles">
|
||||
{careerObjects.map((o) => (
|
||||
<option key={o.soc_code} value={o.title} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
|
||||
{/* change / clear link */}
|
||||
{selectedObj && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="text-blue-600 underline text-sm mt-1"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import './Dashboard.css'; // or Tailwind classes
|
||||
|
||||
export function CareerSuggestions({
|
||||
careerSuggestions = [],
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import axios from "axios";
|
||||
import "./Chatbot.css";
|
||||
|
||||
import "../styles/legacy/Chatbot.legacy.css";
|
||||
const Chatbot = ({ context }) => {
|
||||
const [messages, setMessages] = useState([
|
||||
{
|
||||
|
@ -16,7 +16,7 @@ import PopoutPanel from './PopoutPanel.js';
|
||||
import CareerSearch from './CareerSearch.js'; // <--- Import your new search
|
||||
import Chatbot from './Chatbot.js';
|
||||
|
||||
import './Dashboard.css';
|
||||
import "../styles/legacy/Dashboard.legacy.css";
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import { fetchSchools } from '../utils/apiUtils.js';
|
||||
|
||||
|
@ -62,7 +62,7 @@ function InterestMeaningModal({
|
||||
{/* Always ask for meaning rating */}
|
||||
<div className="mb-4">
|
||||
<label className="block font-medium mb-1">
|
||||
How Meaningful is This Career to You? (1–5)
|
||||
How Meaningful is This Career to You? (i.e. how you feel this career's contributions to society rank) (1–5)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
|
@ -1,194 +0,0 @@
|
||||
.loan-repayment-container {
|
||||
width: 100%;
|
||||
max-width: 500px; /* Adjust as needed */
|
||||
margin: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.loan-repayment-container form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.loan-repayment-container .input-group {
|
||||
display: flex;
|
||||
align-items: center; /* Ensures labels and inputs are aligned */
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loan-repayment-container label {
|
||||
font-weight: bold;
|
||||
width: 40%; /* Consistent width for labels */
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.loan-repayment-container input,
|
||||
.loan-repayment-container select {
|
||||
width: 58%; /* Consistent width for inputs */
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.loan-repayment-container .calculate-button-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.loan-repayment-container button {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
padding: 10px;
|
||||
background-color: green;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loan-repayment-container button:hover {
|
||||
background-color: green;
|
||||
}
|
||||
|
||||
.loan-repayment-fields label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.loan-repayment-fields input,
|
||||
.loan-repayment-fields select {
|
||||
height: 40px; /* Set a consistent height */
|
||||
padding: 0 10px; /* Add horizontal padding */
|
||||
font-size: 1rem; /* Consistent font size */
|
||||
width: 100%; /* Make inputs span full width */
|
||||
box-sizing: border-box; /* Include padding in total width */
|
||||
margin-bottom: 15px; /* Space between fields */
|
||||
border: 1px solid #ccc; /* Light border for all fields */
|
||||
border-radius: 8px; /* Rounded corners */
|
||||
}
|
||||
|
||||
.loan-repayment-fields button {
|
||||
width: 100%; /* Full width for button */
|
||||
padding: 12px;
|
||||
height: 45px;
|
||||
margin-top: 10px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
justify-content: center; /* Horizontally center text */
|
||||
align-items: center; /* Vertically center text */
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.loan-repayment-fields button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
/* Make the container for the fields more clean */
|
||||
.loan-repayment-fields {
|
||||
max-width: 600px;
|
||||
margin: 0 auto; /* Center the form */
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Optional: Adjust margins for form fields */
|
||||
.loan-repayment-fields input,
|
||||
.loan-repayment-fields select,
|
||||
.loan-repayment-fields button {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Ensure the heading of the Loan Repayment Analysis is centered */
|
||||
.loan-repayment-fields h3 {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: right; /* Centers the button text */
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
.results-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); /* Limit the column size to a minimum of 280px */
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
max-width: 1200px; /* Limit the maximum width of the grid */
|
||||
margin: 0 auto; /* Center the grid horizontally */
|
||||
}
|
||||
|
||||
.results-container h3 {
|
||||
grid-column: span 3; /* Ensure the header spans across the entire grid */
|
||||
text-align: center; /* Align the text to the center */
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.school-result-card {
|
||||
border: 1px solid #ddd;
|
||||
padding: 15px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.school-result-card h4 {
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.school-result-card p {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.school-result-card .net-gain.positive {
|
||||
color: #2ecc71; /* Green color for positive values */
|
||||
}
|
||||
|
||||
.school-result-card .net-gain.negative {
|
||||
color: #e74c3c; /* Red color for negative values */
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
margin-top: 10px;
|
||||
}
|
@ -1,6 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import './LoanRepayment.css';
|
||||
|
||||
function LoanRepayment({
|
||||
schools,
|
||||
salaryData,
|
||||
|
@ -206,7 +206,7 @@ const saveInlineMilestone = async (m) => {
|
||||
const impPayload = {
|
||||
milestone_id : saved.id,
|
||||
impact_type : imp.impact_type,
|
||||
direction : imp.direction,
|
||||
direction : imp.impact_type === "salary" ? "add" : imp.direction,
|
||||
amount : parseFloat(imp.amount) || 0,
|
||||
start_date : toSqlDate(imp.start_date) || null,
|
||||
end_date : toSqlDate(imp.end_date) || null
|
||||
@ -304,7 +304,7 @@ const saveNewMilestone = async () => {
|
||||
const impPayload = {
|
||||
milestone_id : created.id,
|
||||
impact_type : imp.impact_type,
|
||||
direction : imp.direction,
|
||||
direction : imp.impact_type === "salary" ? "add" : imp.direction,
|
||||
amount : parseFloat(imp.amount) || 0,
|
||||
start_date : toSqlDate(imp.start_date) || null,
|
||||
end_date : toSqlDate(imp.end_date) || null
|
||||
@ -429,13 +429,23 @@ const saveNewMilestone = async () => {
|
||||
<option value="MONTHLY">Monthly</option>
|
||||
</select>
|
||||
<label>Direction:</label>
|
||||
<select
|
||||
value={imp.direction}
|
||||
onChange={(e) => updateInlineImpact(m.id, idx, "direction", e.target.value)}
|
||||
>
|
||||
<option value="add">Add</option>
|
||||
<option value="subtract">Subtract</option>
|
||||
</select>
|
||||
{imp.impact_type !== "salary" && (
|
||||
<select
|
||||
value={imp.direction}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setNewMilestoneData((prev) => {
|
||||
const copy = [...prev.impacts];
|
||||
copy[idx] = { ...copy[idx], direction: val };
|
||||
return { ...prev, impacts: copy };
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="add">Add</option>
|
||||
<option value="subtract">Subtract</option>
|
||||
</select>
|
||||
)}
|
||||
|
||||
<label>Amount:</label>
|
||||
<input
|
||||
type="number"
|
||||
@ -503,37 +513,26 @@ const saveNewMilestone = async () => {
|
||||
<h6>Impacts</h6>
|
||||
{newMilestoneData.impacts.map((imp, idx) => (
|
||||
<div key={idx} style={{ border: "1px solid #bbb", margin: "0.5rem 0", padding: "0.3rem" }}>
|
||||
<label>Type:</label>
|
||||
<select
|
||||
value={imp.impact_type}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setNewMilestoneData((prev) => {
|
||||
const copy = [...prev.impacts];
|
||||
copy[idx] = { ...copy[idx], impact_type: val };
|
||||
return { ...prev, impacts: copy };
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="salary">Salary (annual)</option>
|
||||
<option value="ONE_TIME">One-Time</option>
|
||||
<option value="MONTHLY">Monthly</option>
|
||||
</select>
|
||||
<label>Direction:</label>
|
||||
<select
|
||||
value={imp.direction}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setNewMilestoneData((prev) => {
|
||||
const copy = [...prev.impacts];
|
||||
copy[idx] = { ...copy[idx], direction: val };
|
||||
return { ...prev, impacts: copy };
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="add">Add</option>
|
||||
<option value="subtract">Subtract</option>
|
||||
</select>
|
||||
{/* Direction – show only when NOT salary */}
|
||||
{imp.impact_type !== "salary" && (
|
||||
<>
|
||||
<label>Add or Subtract?</label>
|
||||
<select
|
||||
value={imp.direction}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setNewMilestoneData((prev) => {
|
||||
const copy = [...prev.impacts];
|
||||
copy[idx] = { ...copy[idx], direction: val };
|
||||
return { ...prev, impacts: copy };
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="add">Add</option>
|
||||
<option value="subtract">Subtract</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
<label>Amount:</label>
|
||||
<input
|
||||
type="number"
|
||||
|
@ -2,10 +2,16 @@
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
export default function MilestonePanel({ groups, onEdit, onSelect }) {
|
||||
export default function MilestonePanel({
|
||||
groups = [],
|
||||
onEdit = () => {},
|
||||
onSelect = () => {},
|
||||
onAddNewMilestone = () => {}
|
||||
}) {
|
||||
return (
|
||||
<aside className="w-full md:max-w-md mx-auto"> {/* max-width ≈ 28 rem */}
|
||||
<aside className="w-full md:max-w-md mx-auto">
|
||||
|
||||
{/* ── accordion groups ─────────────────────────── */}
|
||||
{groups.map((g) => (
|
||||
<details key={g.month} className="mb-3">
|
||||
<summary className="cursor-pointer font-semibold flex items-center gap-1">
|
||||
@ -17,30 +23,36 @@ export default function MilestonePanel({ groups, onEdit, onSelect }) {
|
||||
{g.items.map((m) => (
|
||||
<li
|
||||
key={m.id}
|
||||
className="grid grid-cols-[1fr_auto] items-center gap-4 pr-2
|
||||
hover:bg-gray-50 rounded cursor-pointer"
|
||||
onClick={() => onSelect(m)}
|
||||
className="grid grid-cols-[1fr_auto] items-center gap-4 pr-2 hover:bg-gray-50 rounded cursor-pointer"
|
||||
onClick={() => onSelect(m)}
|
||||
>
|
||||
<span className="truncate">{m.title}</span>
|
||||
|
||||
{/* edit pencil */}
|
||||
<Button
|
||||
onClick={(e) => { {/* stop click bubbling so pencil still edits */}
|
||||
e.stopPropagation();
|
||||
onEdit(m);
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-blue-600 hover:bg-blue-50"
|
||||
aria-label="Edit milestone"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); /* keep row-click for drawer */
|
||||
onEdit(m);
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</li>
|
||||
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
))}
|
||||
|
||||
{/* ── footer action bar ────────────────────────── */}
|
||||
<div className="border-t px-4 py-3 flex justify-end">
|
||||
<Button variant="secondary" onClick={onAddNewMilestone}>
|
||||
+ Add Milestone
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
@ -8,32 +8,34 @@ const Paywall = () => {
|
||||
const { selectedCareer } = state || {};
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
navigate('/signin');
|
||||
return;
|
||||
}
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return navigate('/signin');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/activate-premium', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
try {
|
||||
const res = await fetch('/api/activate-premium', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
navigate('/premium-onboarding', { state: { selectedCareer } });
|
||||
} else if (response.status === 401) {
|
||||
navigate('/GettingStarted', { state: { selectedCareer } });
|
||||
} else {
|
||||
console.error('Failed to activate premium:', await response.text());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error activating premium:', err);
|
||||
if (res.status === 401) return navigate('/signin-landing');
|
||||
|
||||
if (res.ok) {
|
||||
// 1) grab the fresh token / profile if the API returns it
|
||||
const { token: newToken, user } = await res.json().catch(() => ({}));
|
||||
if (newToken) localStorage.setItem('token', newToken);
|
||||
if (user) window.dispatchEvent(new Event('user-updated')); // or your context setter
|
||||
|
||||
// 2) give the auth context time to update, then push
|
||||
navigate('/premium-onboarding', { replace: true, state: { selectedCareer } });
|
||||
} else {
|
||||
console.error('activate-premium failed:', await res.text());
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error activating premium:', err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="paywall">
|
||||
|
@ -15,6 +15,11 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
const [currentlyWorking, setCurrentlyWorking] = useState('');
|
||||
const [selectedCareer, setSelectedCareer] = useState('');
|
||||
const [collegeEnrollmentStatus, setCollegeEnrollmentStatus] = useState('');
|
||||
const [showFinPrompt, setShowFinPrompt] = useState(false);
|
||||
const [financialReady, setFinancialReady] = useState(false); // persisted later if you wish
|
||||
const Req = () => <span className="text-red-600 ml-0.5">*</span>;
|
||||
const ready = selectedCareer && currentlyWorking && collegeEnrollmentStatus;
|
||||
|
||||
|
||||
// 1) Grab the location state values, if any
|
||||
const location = useLocation();
|
||||
@ -85,7 +90,7 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block font-medium">
|
||||
Are you currently earning any income — even part-time or outside your intended career path?
|
||||
Are you currently earning any income — even part-time or outside your intended career path? <Req />
|
||||
</label>
|
||||
<p className="text-sm text-gray-600">
|
||||
(We ask this to understand your financial picture. This won’t affect how we track your progress toward your target career.)
|
||||
@ -96,6 +101,7 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
setCurrentlyWorking(e.target.value);
|
||||
setData(prev => ({ ...prev, currently_working: e.target.value }));
|
||||
}}
|
||||
required
|
||||
className="w-full border rounded p-2"
|
||||
>
|
||||
<option value="">Select one</option>
|
||||
@ -107,13 +113,13 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
{/* 2) Replace old local “Search for Career” with <CareerSearch/> */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">
|
||||
What career are you planning to pursue?
|
||||
What career are you planning to pursue? <Req />
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
This should be your <strong>target career path</strong> — whether it’s a new goal or the one you're already in.
|
||||
</p>
|
||||
|
||||
<CareerSearch onCareerSelected={handleCareerSelected} />
|
||||
<CareerSearch onCareerSelected={handleCareerSelected} required />
|
||||
</div>
|
||||
|
||||
{selectedCareer && (
|
||||
@ -161,14 +167,17 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block font-medium">
|
||||
Are you currently enrolled in college or planning to enroll?
|
||||
Are you currently enrolled in college or planning to enroll? <Req />
|
||||
</label>
|
||||
<select
|
||||
value={collegeEnrollmentStatus}
|
||||
onChange={(e) => {
|
||||
setCollegeEnrollmentStatus(e.target.value);
|
||||
setData(prev => ({ ...prev, college_enrollment_status: e.target.value }));
|
||||
const needsPrompt = ['currently_enrolled', 'prospective_student'].includes(e.target.value);
|
||||
setShowFinPrompt(needsPrompt);
|
||||
}}
|
||||
required
|
||||
className="w-full border rounded p-2"
|
||||
>
|
||||
<option value="">Select one</option>
|
||||
@ -176,6 +185,39 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
<option value="currently_enrolled">Currently Enrolled</option>
|
||||
<option value="prospective_student">Planning to Enroll (Prospective)</option>
|
||||
</select>
|
||||
|
||||
{showFinPrompt && !financialReady && (
|
||||
<div className="mt-4 p-4 rounded border border-blue-300 bg-blue-50">
|
||||
<p className="text-sm mb-3">
|
||||
We can give you step-by-step milestones right away. <br />
|
||||
If you’d also like a personal cash-flow & tuition projection,<br />
|
||||
add a few financial details now—or skip and do it later.
|
||||
</p>
|
||||
|
||||
<button
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white py-1 px-3 rounded mr-2"
|
||||
onClick={() => {
|
||||
/* open your ScenarioEditModal or a mini financial modal */
|
||||
setShowFinPrompt(false);
|
||||
/* maybe set a flag so CareerRoadmap opens the modal */
|
||||
}}
|
||||
>
|
||||
Add Financial Details
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-800 py-1 px-3 rounded"
|
||||
onClick={() => {
|
||||
setFinancialReady(false); // they chose to skip
|
||||
setShowFinPrompt(false);
|
||||
nextStep(); // continue onboarding
|
||||
}}
|
||||
>
|
||||
Skip for Now
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block font-medium">Career Goals</label>
|
||||
@ -196,7 +238,11 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded"
|
||||
disabled={!ready}
|
||||
className={`py-2 px-4 rounded font-semibold
|
||||
${selectedCareer && currentlyWorking && collegeEnrollmentStatus
|
||||
? 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'}`}
|
||||
>
|
||||
Financial →
|
||||
</button>
|
||||
|
@ -122,7 +122,7 @@ export default function ScenarioEditModal({
|
||||
|
||||
/*********************************************************
|
||||
* 7) Whenever the **modal is shown** *or* **scenario.id changes**
|
||||
+ * → hydrate the form + careerSearch box.
|
||||
* → hydrate the form + careerSearch box.
|
||||
*********************************************************/
|
||||
useEffect(() => {
|
||||
if (!show || !scenario) return;
|
||||
@ -139,14 +139,10 @@ export default function ScenarioEditModal({
|
||||
status : safe(s.status || 'planned'),
|
||||
start_date : safe(s.start_date),
|
||||
projected_end_date : safe(s.projected_end_date),
|
||||
retirement_start_date: safe(
|
||||
(s.retirement_start_date || s.projected_end_date || '')
|
||||
.toString() // handles Date objects
|
||||
.substring(0, 10) // keep YYYY-MM-DD
|
||||
),
|
||||
desired_retirement_income_monthly :
|
||||
safe(s.desired_retirement_income_monthly
|
||||
?? financialProfile?.monthly_expenses),
|
||||
retirement_start_date: safe(s.retirement_start_date),
|
||||
desired_retirement_income_monthly : safe(
|
||||
s.desired_retirement_income_monthly
|
||||
),
|
||||
|
||||
planned_monthly_expenses : safe(s.planned_monthly_expenses),
|
||||
planned_monthly_debt_payments : safe(s.planned_monthly_debt_payments),
|
||||
@ -234,9 +230,6 @@ export default function ScenarioEditModal({
|
||||
formData.credit_hours_required
|
||||
]);
|
||||
|
||||
/*********************************************************
|
||||
* 9) Career auto-suggest
|
||||
*********************************************************/
|
||||
/*********************************************************
|
||||
* 9) Career auto-suggest
|
||||
*********************************************************/
|
||||
@ -1202,6 +1195,11 @@ if (formData.retirement_start_date) {
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
{!formData.retirement_start_date && (
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
Pick a Planned Retirement Date to run the simulation.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show a preview if we have simulation data */}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import './SchoolFilters.css';
|
||||
|
||||
import "../styles/legacy/SchoolFilters.legacy.css";
|
||||
function SchoolFilters({ schools, setFilteredSchools }) {
|
||||
const [sortBy, setSortBy] = useState('tuition'); // Default: Sort by Tuition
|
||||
const [tuitionRange, setTuitionRange] = useState([0, 50000]); // Example range
|
||||
|
@ -1,8 +1,10 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import React, { useRef, useState, useEffect, useContext } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { ProfileCtx } from '../App.js';
|
||||
|
||||
function SignIn({ setIsAuthenticated, setUser }) {
|
||||
const navigate = useNavigate();
|
||||
const { setFinancialProfile, setScenario } = useContext(ProfileCtx);
|
||||
const usernameRef = useRef('');
|
||||
const passwordRef = useRef('');
|
||||
const [error, setError] = useState('');
|
||||
@ -18,51 +20,71 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
||||
}, [location.search]);
|
||||
|
||||
const handleSignIn = async (event) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
|
||||
const username = usernameRef.current.value;
|
||||
const password = passwordRef.current.value;
|
||||
// 0️⃣ clear everything that belongs to the *previous* user
|
||||
localStorage.removeItem('careerSuggestionsCache');
|
||||
localStorage.removeItem('lastSelectedCareerProfileId');
|
||||
localStorage.removeItem('aiClickCount');
|
||||
localStorage.removeItem('aiClickDate');
|
||||
localStorage.removeItem('aiRecommendations');
|
||||
localStorage.removeItem('premiumOnboardingState');
|
||||
localStorage.removeItem('financialProfile'); // if you cache it
|
||||
localStorage.removeItem('selectedScenario');
|
||||
|
||||
if (!username || !password) {
|
||||
setError('Please enter both username and password');
|
||||
return;
|
||||
}
|
||||
const username = usernameRef.current.value;
|
||||
const password = passwordRef.current.value;
|
||||
|
||||
try {
|
||||
const response = await fetch('https://dev1.aptivaai.com/api/signin', { // <-here
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!username || !password) {
|
||||
setError('Please enter both username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to sign in');
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('https://dev1.aptivaai.com/api/signin', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
// Destructure user, which includes is_premium, etc.
|
||||
const { token, id, user } = data;
|
||||
const data = await resp.json(); // ← read ONCE
|
||||
|
||||
// Store token & id in localStorage
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('id', id);
|
||||
if (!resp.ok) throw new Error(data.error || 'Failed to sign in');
|
||||
|
||||
// Mark user as authenticated
|
||||
setIsAuthenticated(true);
|
||||
/* ---------------- success path ---------------- */
|
||||
const { token, id, user } = data;
|
||||
|
||||
// Store the full user object in state, so we can check user.is_premium, etc.
|
||||
if (setUser && user) {
|
||||
setUser(user);
|
||||
|
||||
navigate('/signin-landing'); // fallback if undefined
|
||||
}
|
||||
// fetch current user profile immediately
|
||||
const profileRes = await fetch('/api/user-profile', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const profile = await profileRes.json();
|
||||
setFinancialProfile(profile);
|
||||
setScenario(null); // or fetch latest scenario separately
|
||||
|
||||
} catch (error) {
|
||||
setError(error.message);
|
||||
}
|
||||
};
|
||||
/* purge any leftovers from prior session */
|
||||
['careerSuggestionsCache',
|
||||
'lastSelectedCareerProfileId',
|
||||
'aiClickCount',
|
||||
'aiClickDate',
|
||||
'aiRecommendations',
|
||||
'premiumOnboardingState',
|
||||
'financialProfile',
|
||||
'selectedScenario'
|
||||
].forEach(k => localStorage.removeItem(k));
|
||||
|
||||
/* store new session data */
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('id', id);
|
||||
|
||||
setIsAuthenticated(true);
|
||||
setUser(user);
|
||||
navigate('/signin-landing');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -105,7 +127,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
||||
<p className="mt-4 text-center text-sm text-gray-600">
|
||||
Don’t have an account?{' '}
|
||||
<Link
|
||||
//to="/signup" // <- here
|
||||
to="/signup" // <- here
|
||||
className="font-medium text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Sign Up
|
||||
|
@ -56,7 +56,7 @@ function SignUp() {
|
||||
const [areas, setAreas] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [loadingAreas, setLoadingAreas] = useState(false);
|
||||
const [phone, setPhone] = useState('');
|
||||
const [phone, setPhone] = useState('+1');
|
||||
const [optIn, setOptIn] = useState(false);
|
||||
|
||||
const [showCareerSituations, setShowCareerSituations] = useState(false);
|
||||
@ -108,10 +108,11 @@ function SignUp() {
|
||||
}, [state]);
|
||||
|
||||
const validateFields = async () => {
|
||||
const emailRegex = /^[^\s@]@[^\s@]\.[^\s@]{2,}$/;
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||
const zipRegex = /^\d{5}$/;
|
||||
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
|
||||
|
||||
const usPhoneRegex = /^\+1\d{10}$/;
|
||||
|
||||
if (
|
||||
!username || !password || !confirmPassword ||
|
||||
!firstname || !lastname ||
|
||||
@ -146,6 +147,10 @@ function SignUp() {
|
||||
setError('Passwords do not match.');
|
||||
return false;
|
||||
}
|
||||
if (!usPhoneRegex.test(phone)) {
|
||||
setError('Phone number must be +1 followed by 10 digits.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Explicitly check if username exists before proceeding
|
||||
try {
|
||||
@ -274,6 +279,7 @@ return (
|
||||
/>
|
||||
<input
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
@ -287,13 +293,21 @@ return (
|
||||
/>
|
||||
|
||||
{/* ─────────────── New: Mobile number ─────────────── */}
|
||||
<react-phone-input
|
||||
country="us"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="Mobile (15555555555)"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="tel"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="+15555555555"
|
||||
value={phone}
|
||||
onChange={e => {
|
||||
// keep only digits, enforce leading 1, clamp length
|
||||
let digits = e.target.value.replace(/\D/g, '');
|
||||
if (!digits.startsWith('1')) digits = '1' + digits;
|
||||
setPhone('+' + digits.slice(0, 11)); // +1 + 10 digits max
|
||||
}}
|
||||
pattern="^\+1\d{10}$"
|
||||
title="Enter a U.S. number: +1 followed by 10 digits"
|
||||
/>
|
||||
|
||||
|
||||
{/* New: SMS opt-in checkbox */}
|
||||
<label className="inline-flex items-center gap-2">
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import './ZipCodeInput.css';
|
||||
|
||||
import "../styles/legacy/ZipCodeInput.legacy.css";
|
||||
export function ZipCodeInput({ zipCode, setZipCode, onZipSubmit }) {
|
||||
return (
|
||||
<div className="zip-code-input">
|
||||
|
@ -1,17 +1,41 @@
|
||||
/* index.css – Aptiva base layer */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* --- 1) BODY RESET/COLOURS ------------------------------- */
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-aptiva-gray text-gray-900 antialiased;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
@apply font-semibold text-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
/* --- 2) REUSABLE COMPONENT CLASSES ----------------------- */
|
||||
@layer components {
|
||||
/* Primary button */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center gap-2
|
||||
rounded-xl bg-aptiva hover:bg-aptiva-dark
|
||||
text-white font-medium px-4 py-2
|
||||
shadow transition disabled:opacity-50;
|
||||
}
|
||||
|
||||
/* Secondary outline button */
|
||||
.btn-outline {
|
||||
@apply inline-flex items-center gap-2
|
||||
rounded-xl border border-aptiva text-aptiva
|
||||
px-4 py-2 hover:bg-aptiva-light/10;
|
||||
}
|
||||
|
||||
/* Card container */
|
||||
.card {
|
||||
@apply rounded-xl bg-white shadow p-6;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- 3) OPTIONAL UTILITIES -------------------------------- */
|
||||
/* Example: .scrollbar-thin from tailwind-scrollbar plugin */
|
||||
/* … */
|
||||
|
@ -1,9 +1,31 @@
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/**/*.{js,jsx,ts,tsx}', // Ensure this matches your file structure
|
||||
],
|
||||
content: ['./src/**/*.{js,jsx,ts,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
/* brand colours */
|
||||
colors: {
|
||||
aptiva: {
|
||||
DEFAULT : '#0A84FF', // primary blue
|
||||
dark : '#005FCC',
|
||||
light : '#3AA0FF',
|
||||
accent : '#FF7C00', // accent orange
|
||||
gray : '#F7FAFC', // page bg
|
||||
},
|
||||
},
|
||||
/* fonts */
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['Fira Code', 'monospace'],
|
||||
},
|
||||
/* radii */
|
||||
borderRadius: {
|
||||
xl: '1rem', // 16 px extra-round corners everywhere
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'), // prettier inputs
|
||||
require('@tailwindcss/typography'), // prose-class for AI answers
|
||||
],
|
||||
};
|
||||
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user