UI overhaul, UX fixes

This commit is contained in:
Josh 2025-06-30 13:20:18 +00:00
parent 693d43190b
commit cd2a99df44
33 changed files with 1248 additions and 979 deletions

View File

@ -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 permissionno 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 cant meet the rule, ASK the user a clarifying question instead
of returning an invalid milestone.
NO extra text or disclaimers if returning a planonly 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

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -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>
);
}

View File

@ -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">

View File

@ -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 dont see your exact title, please choose the closest matchthis 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>
);
};

View File

@ -1,5 +1,4 @@
import React from 'react';
import './Dashboard.css'; // or Tailwind classes
export function CareerSuggestions({
careerSuggestions = [],

View File

@ -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([
{

View File

@ -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';

View File

@ -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? (15)
How Meaningful is This Career to You? (i.e. how you feel this career's contributions to society rank) (15)
</label>
<input
type="number"

View File

@ -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;
}

View File

@ -1,6 +1,4 @@
import React, { useState } from 'react';
import './LoanRepayment.css';
function LoanRepayment({
schools,
salaryData,

View File

@ -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"

View File

@ -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>
);
}

View File

@ -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">

View File

@ -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 wont 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 its 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 youd also like a personal cash-flow &amp; tuition projection,<br />
add a few financial details nowor 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>

View File

@ -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 */}

View File

@ -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

View File

@ -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">
Dont have an account?{' '}
<Link
//to="/signup" // <- here
to="/signup" // <- here
className="font-medium text-blue-600 hover:text-blue-500"
>
Sign Up

View File

@ -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">

View File

@ -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">

View File

@ -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 */
/* … */

View File

@ -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
],
};

Binary file not shown.