diff --git a/.build.hash b/.build.hash
index d3348bf..51c0d17 100644
--- a/.build.hash
+++ b/.build.hash
@@ -1 +1 @@
-c8af44caf3dec8c5f306fef35c4925be044f0374-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b
+24c4644c626acf48ddca3964105cd9bfa267d82a-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b
diff --git a/.env b/.env
deleted file mode 100644
index 1b8e344..0000000
--- a/.env
+++ /dev/null
@@ -1,8 +0,0 @@
-CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://localhost:3000
-SERVER1_PORT=5000
-SERVER2_PORT=5001
-SERVER3_PORT=5002
-IMG_TAG=ed1fdbb-202508121553
-
-ENV_NAME=dev
-PROJECT=aptivaai-dev
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index b21ab22..c06cc91 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,4 @@ uploads/.env
.env
.env.*
scan-env.sh
+.aptiva-test-user.json
diff --git a/.last-lock b/.last-lock
index 48c332e..99340a5 100644
--- a/.last-lock
+++ b/.last-lock
@@ -1 +1 @@
-1a7fe9191922c4f8389027ed53b6a4909740a48b
+98f674eca26e366aee0b41f250978982060105f0
diff --git a/.lock.hash b/.lock.hash
index 48c332e..99340a5 100644
--- a/.lock.hash
+++ b/.lock.hash
@@ -1 +1 @@
-1a7fe9191922c4f8389027ed53b6a4909740a48b
+98f674eca26e366aee0b41f250978982060105f0
diff --git a/backend/tests/COMPONENTS.md b/backend/tests/COMPONENTS.md
new file mode 100644
index 0000000..866f55a
--- /dev/null
+++ b/backend/tests/COMPONENTS.md
@@ -0,0 +1,37 @@
+
+# AptivaAI Test Components (Curated, Active Only)
+
+> Source of truth for what we **do test**. Keep this file tight and current.
+> Add a component/feature here **before** adding tests.
+
+## ✅ Active Components
+
+### A. Auth & Profile (server1)
+- **Feature A1**: SignUp → SignIn (cookie) → User Profile (JIT-PII: no `id`)
+ - Test: `backend/tests/auth_signup_signin.mjs`
+
+### B. Support & Messaging (server3)
+- **Feature B1**: `/api/support` auth, dedupe, rate limits, negatives (invalid category, short message)
+ - Test: `backend/tests/support_limits.mjs`
+
+### C. Subscription & Paywall (server3)
+- **Feature C1**: `/api/premium/subscription/status` returns `{ is_premium:false, is_pro_premium:false }` for new user; unauth → 401
+ - Test: `backend/tests/subscription_status.mjs`
+
+---
+
+## 🟨 Pending Confirmation (do **not** test until moved above)
+- Premium Onboarding draft save/load (server3)
+- Career data & caching (server2) – salary & O*NET warm-cache
+- Loan Repayment & ROI (free)
+- Milestones & AI suggestions (server3)
+- Financial Projection service (frontend utils server3)
+- College Mode (premium)
+- Reminders & Twilio (server3)
+- AI chat risk analysis consumption (server3)
+- Nginx/Secrets/CSP checks
+- DB connectivity (MySQL SSL) & SQLite reads
+- Caching & file safety
+- Logging (rid present, no tokens/PII)
+
+> Move items up only after you confirm they’re current and in scope.
diff --git a/backend/tests/auth_signup_signin.mjs b/backend/tests/auth_signup_signin.mjs
new file mode 100644
index 0000000..161671e
--- /dev/null
+++ b/backend/tests/auth_signup_signin.mjs
@@ -0,0 +1,126 @@
+// Run:
+// node backend/tests/auth_signup_signin.mjs
+// BASE=https://staging.aptivaai.com ALLOW_NON_DEV=1 node backend/tests/auth_signup_signin.mjs
+//
+// Behavior:
+// - Creates a brand-new user each run (unique email/username)
+// - Cookie-based auth only (captures Set-Cookie from register/signin)
+// - Verifies /api/signin returns { message }, /api/user-profile returns 200 JSON w/ NO id leakage
+// - Verifies /api/user-profile?fields=… respects allowlist
+// - Verifies /api/logout clears cookie and subsequent /api/user-profile is unauthorized
+// - Defaults to dev; requires ALLOW_NON_DEV=1 to run on non-dev BASE
+
+import assert from 'node:assert/strict';
+
+const BASE = process.env.BASE || 'https://dev1.aptivaai.com';
+if (BASE !== 'https://dev1.aptivaai.com' && process.env.ALLOW_NON_DEV !== '1') {
+ console.error(`Refusing to run against non-dev BASE='${BASE}'. Set ALLOW_NON_DEV=1 to override.`);
+ process.exit(2);
+}
+
+const j = (o) => JSON.stringify(o);
+const rand = () => Math.random().toString(36).slice(2, 10);
+const email = `jcoakley@aptivaai.com`;
+const username = `qa_${rand()}`;
+const password = `Aa1!${rand()}Z`;
+
+let cookie = ''; // session cookie (auth)
+function captureSetCookie(headers) {
+ // In ESM fetch, headers.get('set-cookie') returns the first Set-Cookie (enough for session)
+ const sc = headers.get('set-cookie');
+ if (sc) cookie = sc.split(';')[0];
+}
+
+async function req(path, { method = 'GET', headers = {}, body } = {}) {
+ const h = {
+ 'Content-Type': 'application/json',
+ ...(cookie ? { Cookie: cookie } : {}),
+ ...headers,
+ };
+ const res = await fetch(`${BASE}${path}`, {
+ method,
+ headers: h,
+ body: body ? j(body) : undefined,
+ });
+ const text = await res.text();
+ let json = null;
+ try { json = JSON.parse(text); } catch {}
+ return { res, text, json };
+}
+
+(async () => {
+ // 1) Register (201)
+ {
+ const { res, json } = await req('/api/register', {
+ method: 'POST',
+ body: {
+ username,
+ password,
+ firstname: 'QA',
+ lastname: 'Bot',
+ email,
+ zipcode: '30024',
+ state: 'GA',
+ area: 'Atlanta',
+ career_situation: 'planning',
+ },
+ });
+ assert.equal(res.status, 201, `register should 201, got ${res.status}`);
+ captureSetCookie(res.headers);
+ assert.ok(cookie, 'session cookie must be set after register');
+ }
+
+ // 2) Sign in (200) — cookie refreshed, { message } in body
+ {
+ const { res, json } = await req('/api/signin', {
+ method: 'POST',
+ body: { username, password },
+ });
+ assert.equal(res.status, 200, `signin should 200, got ${res.status}`);
+ assert.ok(json && typeof json.message === 'string', 'signin returns { message }');
+ captureSetCookie(res.headers);
+ assert.ok(cookie, 'session cookie must be present after signin');
+ }
+
+ // 3) Profile (200, JSON, no id leakage)
+ {
+ const { res, json, text } = await req('/api/user-profile');
+ assert.equal(res.status, 200, `profile fetch should 200, got ${res.status}, body=${text.slice(0,120)}`);
+ assert.ok(json && typeof json === 'object', 'profile returns JSON object');
+ if ('id' in json || 'user_id' in json) throw new Error('profile must NOT include id/user_id');
+ }
+
+ // 4) Field-filtered profile (allowlist)
+ {
+ const fields = 'firstname,lastname,career_situation';
+ const { res, json, text } = await req(`/api/user-profile?fields=${encodeURIComponent(fields)}`);
+ assert.equal(res.status, 200, `filtered profile should 200, got ${res.status}, body=${text.slice(0,120)}`);
+ const keys = Object.keys(json || {});
+ for (const k of keys) {
+ if (!['firstname','lastname','career_situation','sms_opt_in','phone_e164','email'].includes(k)) {
+ throw new Error(`unexpected field '${k}' in filtered profile`);
+ }
+ }
+ }
+
+ // 5) Username existence
+ {
+ const { res, json } = await req(`/api/check-username/${encodeURIComponent(username)}`);
+ assert.equal(res.status, 200, 'check-username should 200');
+ assert.equal(json?.exists, true, 'new username should exist');
+ }
+
+ // 6) Logout then profile blocked
+ {
+ const out = await req('/api/logout', { method: 'POST' });
+ assert.equal(out.res.status, 200, `logout should 200, got ${out.res.status}`);
+ cookie = ''; // simulate cleared cookie
+ const { res } = await req('/api/user-profile');
+ if (res.status === 200) throw new Error('profile should NOT be accessible after logout');
+ }
+
+ console.log('✓ AUTH regression suite passed');
+})().catch((e) => {
+ console.error('✖ AUTH regression failed:', e?.message || e);
+ process.exit(1);
+});
diff --git a/backend/tests/components_runner.mjs b/backend/tests/components_runner.mjs
new file mode 100644
index 0000000..2517e29
--- /dev/null
+++ b/backend/tests/components_runner.mjs
@@ -0,0 +1,24 @@
+
+ await component('Auth & Profile', [
+ () => feature('SignUp → SignIn → Profile (cookie, no id leakage)',
+ 'backend/tests/auth_signup_signin.mjs'),
+ ]);
+
+ // ─────────────────────────────────────────────────────────────
+ // Component: Support & Messaging (server3)
+ // Feature: /api/support auth, dedupe, rate limits, negatives
+ // ─────────────────────────────────────────────────────────────
+ await component('Support & Messaging', [
+ () => feature('Support: auth/dup/rate-limit/negatives',
+ 'backend/tests/support_limits.mjs', { BURST: process.env.BURST || '20' }),
+ ]);
+
+ // ─────────────────────────────────────────────────────────────
+ // Component: Subscription & Paywall (server3)
+ // Feature: status flags (no PII, default false/false)
+ // ─────────────────────────────────────────────────────────────
+ await component('Subscription & Paywall', [
+ () => feature('Subscription status flags', 'backend/tests/subscription_status.mjs'),
+ ]);
+
+
diff --git a/backend/tests/support_limits.mjs b/backend/tests/support_limits.mjs
new file mode 100644
index 0000000..5d75bd9
--- /dev/null
+++ b/backend/tests/support_limits.mjs
@@ -0,0 +1,174 @@
+
+// Run:
+// node backend/tests/support_limits.mjs
+// BASE=https://staging.aptivaai.com ALLOW_NON_DEV=1 node backend/tests/support_limits.mjs
+//
+// Behavior:
+// - Cookie-based auth only (new user each run, using jcoakley@aptivaai.com by default)
+// - Unauth /api/support → 401
+// - First + immediate duplicate → each may be 2xx/202 (ok), 429 (rate-limited), or 503 (no SENDGRID)
+// Dedupe happens before SENDGRID check, so if first is 503, duplicate often 202 (unless rate-limited).
+// We accept {200,201,202,204,429,503} for each, and require that at least one is not 429.
+//
+import assert from 'node:assert/strict';
+
+const BASE = process.env.BASE || 'https://dev1.aptivaai.com';
+if (BASE !== 'https://dev1.aptivaai.com' && process.env.ALLOW_NON_DEV !== '1') {
+ console.error(`Refusing to run against non-dev BASE='${BASE}'. Set ALLOW_NON_DEV=1 to override.`);
+ process.exit(2);
+}
+
+const j = (o) => JSON.stringify(o);
+const rand = () => Math.random().toString(36).slice(2, 10);
+const email = process.env.QA_EMAIL || 'jcoakley@aptivaai.com';
+const username = `qa_${rand()}`;
+const password = `Aa1!${rand()}Z`;
+
+let cookie = ''; // session cookie (auth)
+function captureSetCookie(headers) {
+ const sc = headers.get('set-cookie');
+ if (sc) cookie = sc.split(';')[0];
+}
+
+async function req(path, { method = 'GET', headers = {}, body } = {}) {
+ const h = {
+ 'Content-Type': 'application/json',
+ ...(cookie ? { Cookie: cookie } : {}),
+ ...headers,
+ };
+ const res = await fetch(`${BASE}${path}`, {
+ method,
+ headers: h,
+ body: body ? j(body) : undefined,
+ });
+ const text = await res.text();
+ let json = null;
+ try { json = JSON.parse(text); } catch {}
+ return { res, text, json };
+}
+
+async function reqNoAuth(path, { method = 'GET', headers = {}, body } = {}) {
+ const h = { 'Content-Type': 'application/json', ...headers };
+ const res = await fetch(`${BASE}${path}`, {
+ method, headers: h, body: body ? j(body) : undefined,
+ });
+ const text = await res.text();
+ let json = null;
+ try { json = JSON.parse(text); } catch {}
+ return { res, text, json };
+}
+
+(async () => {
+ // Register
+ {
+ const { res, json } = await req('/api/register', {
+ method: 'POST',
+ body: {
+ username,
+ password,
+ firstname: 'QA',
+ lastname: 'Bot',
+ email,
+ zipcode: '30024',
+ state: 'GA',
+ area: 'Atlanta',
+ career_situation: 'planning',
+ },
+ });
+ assert.equal(res.status, 201, `register should 201, got ${res.status}`);
+ captureSetCookie(res.headers);
+ assert.ok(cookie, 'session cookie must be set after register');
+ }
+ // Sign in (refresh cookie)
+ {
+ const { res, json } = await req('/api/signin', {
+ method: 'POST',
+ body: { username, password },
+ });
+ assert.equal(res.status, 200, `signin should 200, got ${res.status}`);
+ assert.ok(json && typeof json.message === 'string', 'signin returns { message }');
+ captureSetCookie(res.headers);
+ }
+
+ // Unauthenticated request should 401
+ {
+ const { res } = await reqNoAuth('/api/support', {
+ method: 'POST',
+ body: { subject: 'unauth test', category: 'general', message: 'unauth test message' },
+ });
+ assert.equal(res.status, 401, `unauth /api/support should 401, got ${res.status}`);
+ }
+
+ // First + duplicate: allow {200,201,202,204,429,503}; require at least one NOT 429
+ const dedupePayload = {
+ subject: `QA support ${Date.now()}`,
+ category: 'technical',
+ message: `QA support test ${Date.now()}`
+ };
+ const first = await req('/api/support', { method: 'POST', body: dedupePayload });
+ const dup = await req('/api/support', { method: 'POST', body: dedupePayload });
+ const valid = (s) => [200,201,202,204,429,503].includes(s);
+ if (!valid(first.res.status)) {
+ throw new Error(`/api/support first unexpected ${first.res.status}, body=${first.text.slice(0,120)}`);
+ }
+ if (!valid(dup.res.status)) {
+ throw new Error(`/api/support duplicate unexpected ${dup.res.status}, body=${dup.text.slice(0,120)}`);
+ }
+ const anyNot429 = [first.res.status, dup.res.status].some((s) => s !== 429);
+ if (!anyNot429) {
+ throw new Error(`/api/support first+duplicate were both 429 (statuses: ${first.res.status}, ${dup.res.status})`);
+ }
+
+ console.log('✓ SUPPORT: unauth→401, first+dup→(allowed with ≥1 non-429) — starting burst…');
+
+ // Burst to trigger rate limit (unique messages to avoid dedupe masking)
+ const N = Number(process.env.BURST || 20);
+ const tasks = Array.from({ length: N }, (_, i) =>
+ req('/api/support', {
+ method: 'POST',
+ body: {
+ subject: `burst ${i}`,
+ category: 'technical',
+ message: `burst ${i} ${Date.now()} ${rand()}`
+ },
+ })
+ );
+ const results = await Promise.all(tasks);
+ const codes = results.map(r => r.res.status);
+ const allowed = new Set([200,201,202,204,429,503]);
+ const rlCount = codes.filter(c => c === 429).length;
+
+ if (!codes.every(c => allowed.has(c))) {
+ throw new Error(`unexpected status in burst: ${codes.join(',')}`);
+ }
+
+ if (rlCount < 1) {
+ throw new Error(`expected at least one 429 during burst; got codes=${codes.join(',')}`);
+ }
+
+ // Negative cases: invalid category and too-short message
+ {
+ const badCat = await req('/api/support', {
+ method: 'POST',
+ body: { subject: 'x', category: 'nope', message: 'valid message content' }
+ });
+ if (badCat.res.status !== 400 && badCat.res.status !== 429) {
+ // Allow 429 if limiter tripped; otherwise require 400 for invalid category
+ throw new Error(`/api/support invalid category expected 400 or 429, got ${badCat.res.status}`);
+ }
+ }
+ {
+ const tooShort = await req('/api/support', {
+ method: 'POST',
+ body: { subject: 'x', category: 'general', message: 'hi' } // < 5 chars
+ });
+ if (tooShort.res.status !== 400 && tooShort.res.status !== 429) {
+ throw new Error(`/api/support short message expected 400 or 429, got ${tooShort.res.status}`);
+ }
+ }
+
+console.log('✓ SUPPORT: unauth→401, first+dup→(allowed with ≥1 non-429), burst→(allowed 2xx/429/503 with ≥1 429), negatives→400/429');
+})().catch((e) => {
+ console.error('✖ SUPPORT regression failed:', e?.message || e);
+ process.exit(1);
+});
diff --git a/package-lock.json b/package-lock.json
index cd0ecc2..c5bf38e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -70,6 +70,7 @@
"@babel/parser": "^7.28.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@eslint/js": "^9.32.0",
+ "@playwright/test": "^1.55.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"autoprefixer": "^10.4.21",
@@ -3328,6 +3329,22 @@
"node": ">=14"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
+ "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.55.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz",
@@ -15475,6 +15492,53 @@
"node": ">=4"
}
},
+ "node_modules/playwright": {
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
+ "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.55.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
+ "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
diff --git a/package.json b/package.json
index ecf680d..2b15cf4 100644
--- a/package.json
+++ b/package.json
@@ -104,6 +104,7 @@
"@babel/parser": "^7.28.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@eslint/js": "^9.32.0",
+ "@playwright/test": "^1.55.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"autoprefixer": "^10.4.21",
diff --git a/playwright-report/index.html b/playwright-report/index.html
new file mode 100644
index 0000000..ded81cc
--- /dev/null
+++ b/playwright-report/index.html
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/playwright.config.js b/playwright.config.js
deleted file mode 100644
index 79af517..0000000
--- a/playwright.config.js
+++ /dev/null
@@ -1,6 +0,0 @@
-const { defineConfig } = require('@playwright/test');
-module.exports = defineConfig({
- testDir: 'tests',
- projects:[ {name:'chromium', use:{browserName:'chromium'}} ],
- timeout: 30000,
-});
diff --git a/playwright.config.mjs b/playwright.config.mjs
new file mode 100644
index 0000000..41dc5a5
--- /dev/null
+++ b/playwright.config.mjs
@@ -0,0 +1,15 @@
+ import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ // Limit Playwright to E2E specs only
+ testDir: '/home/jcoakley/aptiva-dev1-app/tests/e2e',
+ testMatch: /.*\.spec\.(?:mjs|js|ts)$/,
+ use: {
+ baseURL: process.env.PW_BASE_URL || 'http://localhost:3000',
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
+ },
+ retries: 1,
+ reporter: [['list'], ['html', { open: 'never' }]],
+ });
\ No newline at end of file
diff --git a/src/App.js b/src/App.js
index b86eba6..ff52db3 100644
--- a/src/App.js
+++ b/src/App.js
@@ -286,6 +286,7 @@ const confirmLogout = async () => {
'aiClickDate',
'aiRecommendations',
'premiumOnboardingState',
+ 'premiumOnboardingPointer',
'financialProfile',
'selectedScenario',
]);
diff --git a/src/components/CareerExplorer.js b/src/components/CareerExplorer.js
index dfde47f..6ae7fb7 100644
--- a/src/components/CareerExplorer.js
+++ b/src/components/CareerExplorer.js
@@ -104,6 +104,7 @@ function CareerExplorer() {
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0);
const [showInterestMeaningModal, setShowInterestMeaningModal] = useState(false);
+ const [isSuggesting, setIsSuggesting] = useState(false);
const fitRatingMap = { Best: 5, Great: 4, Good: 3 };
const jobZoneLabels = {
@@ -162,7 +163,7 @@ function CareerExplorer() {
if (!answers) {
setCareerSuggestions([]);
localStorage.removeItem('careerSuggestionsCache');
- setLoading(true); setProgress(0); setLoading(false);
+ setLoading(true); setProgress(0); setLoading(false); setIsSuggesting(true);
return;
}
@@ -326,7 +327,6 @@ function CareerExplorer() {
setSelectedCareer(career);
setCareerDetails(null);
- setLoading(true);
try {
let cipCode = null;
@@ -393,14 +393,12 @@ function CareerExplorer() {
console.error('[handleCareerClick] fatal:', fatal);
setCareerDetails({ error: `We're sorry, but detailed info for "${career.title}" isn't available right now.` });
} finally {
- setLoading(false);
}
}, [areaTitle, userState]);
// ---------- add-from-search ----------
const handleCareerFromSearch = useCallback((obj) => {
const adapted = { code: obj.soc_code, title: obj.title, cipCode: obj.cip_code, fromManualSearch: true };
- setLoading(true);
setPendingCareerForModal(adapted);
}, []);
@@ -578,10 +576,7 @@ function CareerExplorer() {
Explore Careers - use these tools to find your best fit
{
- setLoading(true);
- setPendingCareerForModal(careerObj);
- }}
+ onCareerSelected={handleCareerFromSearch}
/>
diff --git a/src/components/CollegeProfileForm.js b/src/components/CollegeProfileForm.js
index 03d4095..18da6a7 100644
--- a/src/components/CollegeProfileForm.js
+++ b/src/components/CollegeProfileForm.js
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import apiFetch from '../auth/apiFetch.js';
import moment from 'moment/moment.js';
@@ -48,21 +48,24 @@ const toMySqlDate = iso => {
return iso.replace('T', ' ').slice(0, 19);
};
+
+
export default function CollegeProfileForm() {
const { careerId, id } = useParams(); // id optional
const nav = useNavigate();
- const [cipRows, setCipRows] = useState([]);
const [schoolSug, setSchoolSug] = useState([]);
const [progSug, setProgSug] = useState([]);
const [types, setTypes] = useState([]);
- const [ipeds, setIpeds] = useState([]);
const [schoolValid, setSchoolValid] = useState(true);
const [programValid, setProgramValid] = useState(true);
const [autoGradDate, setAutoGradDate] = useState('');
const [graduationTouched, setGraduationTouched] = useState(false);
const [programLengthTouched, setProgramLengthTouched] = useState(false);
-
- const schoolData = cipRows;
+ const [selectedUnitId, setSelectedUnitId] = useState(null);
+ const schoolPrevRef = useRef('');
+ const programPrevRef = useRef('');
+ const lastSchoolText = useRef('');
+
const [form, setForm] = useState({
career_profile_id : careerId,
@@ -81,6 +84,10 @@ export default function CollegeProfileForm() {
const [autoTuition, setAutoTuition] = useState(0);
+ const firstOfNextMonth = (d) => {
+ return moment(d).startOf('month').add(1, 'month').format('YYYY-MM-DD');
+};
+
// ---------- handlers (inside component) ----------
const handleFieldChange = (e) => {
const { name, value, type, checked } = e.target;
@@ -98,6 +105,11 @@ const handleFieldChange = (e) => {
) {
draft[name] = value === '' ? '' : parseFloat(value);
if (name === 'program_length') setProgramLengthTouched(true);
+ if (name === 'program_type') {
+ draft[name] = value;
+ draft.credit_hours_required = '';
+ setProgramLengthTouched(false);
+ }
} else {
draft[name] = value;
}
@@ -105,59 +117,95 @@ const handleFieldChange = (e) => {
});
};
-const onSchoolInput = (e) => {
- handleFieldChange(e);
- const v = e.target.value.toLowerCase();
- const suggestions = cipRows
- .filter((r) => r.INSTNM.toLowerCase().includes(v))
- .map((r) => r.INSTNM);
- setSchoolSug([...new Set(suggestions)].slice(0, 10));
-};
-
-const onProgramInput = (e) => {
- handleFieldChange(e);
- if (!form.selected_school) return;
- const v = e.target.value.toLowerCase();
- const sug = cipRows
- .filter(
- (r) =>
- r.INSTNM.toLowerCase() === form.selected_school.toLowerCase() &&
- r.CIPDESC.toLowerCase().includes(v)
- )
- .map((r) => r.CIPDESC);
- setProgSug([...new Set(sug)].slice(0, 10));
-};
-
-// Prefill school suggestions when form loads or school changes
-useEffect(() => {
- const v = (form.selected_school || '').toLowerCase().trim();
- if (!v || !cipRows.length) {
- setSchoolSug([]);
- return;
- }
- const suggestions = cipRows
- .filter(r => (r.INSTNM || '').toLowerCase().includes(v))
- .map(r => r.INSTNM);
- setSchoolSug([...new Set(suggestions)].slice(0, 10));
-}, [form.selected_school, cipRows]);
-
-// Prefill program suggestions when form loads or program/school changes
-useEffect(() => {
- const sch = (form.selected_school || '').toLowerCase().trim();
- const q = (form.selected_program || '').toLowerCase().trim();
- if (!sch || !q || !cipRows.length) {
+const onSchoolInput = async (e) => {
+ handleFieldChange(e);
+ const value = e.target.value || '';
+ // If school text changed at all, clear program suggestions and program/type selections
+ if (value !== lastSchoolText.current) {
setProgSug([]);
- return;
+ setTypes([]);
+ programPrevRef.current = '';
+ setForm(prev => ({
+ ...prev,
+ selected_program: value ? prev.selected_program : '', // clear if user erased school
+ program_type: ''
+ }));
+ lastSchoolText.current = value;
}
- const sug = cipRows
- .filter(r =>
- (r.INSTNM || '').toLowerCase() === sch &&
- (r.CIPDESC || '').toLowerCase().includes(q)
- )
- .map(r => r.CIPDESC);
- setProgSug([...new Set(sug)].slice(0, 10));
-}, [form.selected_school, form.selected_program, cipRows]);
+ if (!value.trim()) { setSchoolSug([]); schoolPrevRef.current = ''; return; }
+ const it = e?.nativeEvent?.inputType; // 'insertReplacementText' on datalist pick
+ const rep = it === 'insertReplacementText';
+ const big = Math.abs(value.length - (schoolPrevRef.current || '').length) > 1;
+ try {
+ const resp = await authFetch(`/api/schools/suggest?query=${encodeURIComponent(value)}&limit=10`);
+ const arr = resp.ok ? await resp.json() : [];
+ const opts = Array.isArray(arr) ? arr : [];
+ setSchoolSug(opts); // [{ name, unitId }]
+ const exact = opts.find(o => (o.name || '').toLowerCase() === value.toLowerCase());
+ if (exact && (rep || big)) {
+ setSelectedUnitId(exact.unitId ?? null);
+ setForm(prev => ({
+ ...prev,
+ selected_school : exact.name,
+ selected_program: '',
+ program_type : ''
+ }));
+ setProgSug([]); setTypes([]);
+ }
+ } catch { setSchoolSug([]); }
+ schoolPrevRef.current = value;
+ };
+const onProgramInput = async (e) => {
+ handleFieldChange(e);
+ const school = (form.selected_school || '').trim();
+ const value = e.target.value || '';
+ if (!school || !value) { setProgSug([]); programPrevRef.current = value; return; }
+
+ const it = e?.nativeEvent?.inputType; // 'insertReplacementText' when choosing from datalist
+ const rep = it === 'insertReplacementText';
+ const big = Math.abs(value.length - (programPrevRef.current || '').length) > 1;
+
+ try {
+ const resp = await authFetch(`/api/programs/suggest?school=${encodeURIComponent(school)}&query=${encodeURIComponent(value)}&limit=10`);
+ const arr = resp.ok ? await resp.json() : [];
+ const opts = Array.isArray(arr) ? arr : []; // [{ program }]
+ setProgSug(opts);
+
+ // Early commit if exact match was selected from the list (prevents double-pick annoyance)
+ const exact = opts.find(p => (p.program || '').toLowerCase() === value.toLowerCase());
+ if (exact && (rep || big)) {
+ setForm(prev => ({ ...prev, selected_program: exact.program }));
+ setTypes([]); // will refetch types below via effect/blur
+ }
+ } catch {
+ setProgSug([]);
+ }
+ programPrevRef.current = value;
+ };
+
+
+ // Prefill program suggestions once school+program exist (e.g., after API load)
+ useEffect(() => {
+ const school = (form.selected_school || '').trim();
+ const q = (form.selected_program || '').trim();
+ if (!school || !q) { setProgSug([]); return; }
+ (async () => {
+ try {
+ const resp = await authFetch(`/api/programs/suggest?school=${encodeURIComponent(school)}&query=${encodeURIComponent(q)}&limit=10`);
+ const arr = resp.ok ? await resp.json() : [];
+ setProgSug(Array.isArray(arr) ? arr : []);
+ } catch { setProgSug([]); }
+ })();
+ }, [form.selected_school, form.selected_program]);
+
+ // When selected_school changes (after commit/blur), reset program suggestions/types
+ useEffect(() => {
+ setProgSug([]);
+ setTypes([]);
+ setSelectedUnitId(null);
+ programPrevRef.current = '';
+ }, [form.selected_school]);
useEffect(() => {
if (id && id !== 'new') {
@@ -174,27 +222,60 @@ useEffect(() => {
is_online : !!raw.is_online,
loan_deferral_until_graduation : !!raw.loan_deferral_until_graduation,
};
- setForm(normalized);
- if (normalized.tuition !== undefined && normalized.tuition !== null) {
- setManualTuition(String(normalized.tuition));
- }
+ setForm(normalized);
+ // Show saved tuition immediately; estimator will overwrite when deps change
+ if (normalized.tuition != null) {
+ const n = Number(normalized.tuition);
+ setAutoTuition(Number.isFinite(n) ? n : 0);
+ }
+ if (normalized.unit_id) setSelectedUnitId(normalized.unit_id);
+ // If profile came with school+program, load types so Degree Type select is populated
+ if ((normalized.selected_school || '') && (normalized.selected_program || '')) {
+ try {
+ const resp = await authFetch(
+ `/api/programs/types?school=${encodeURIComponent(normalized.selected_school)}&program=${encodeURIComponent(normalized.selected_program)}`
+ );
+ const data = resp.ok ? await resp.json() : null;
+ setTypes(Array.isArray(data?.types) ? data.types : []);
+ } catch {}
+ }
}
})();
}
}, [careerId, id]);
-
-// 2) keep manualTuition aligned if form.tuition is updated elsewhere
-useEffect(() => {
- if (form.tuition !== undefined && form.tuition !== null) {
- if (manualTuition.trim() === '') {
- setManualTuition(String(form.tuition));
- }
- }
-}, [form.tuition]);
+
async function handleSave(){
try{
- const body = normalisePayload({ ...form, tuition: chosenTuition, career_profile_id: careerId });
+
+ // Compute chosen tuition exactly like Onboarding (manual override wins; blank => auto)
+ const chosenTuition =
+ (manualTuition.trim() === '')
+ ? autoTuition
+ : (Number.isFinite(parseFloat(manualTuition)) ? parseFloat(manualTuition) : autoTuition);
+
+ // Confirm user actually picked from list (one alert, on Save only)
+ const school = (form.selected_school || '').trim().toLowerCase();
+ const prog = (form.selected_program || '').trim().toLowerCase();
+ // validate against current server suggestions (not local files)
+ const exactSchool = school && schoolSug.find(o =>
+ (o.name || '').toLowerCase() === school
+ );
+
+ if (school && !exactSchool) {
+ setSchoolValid(false);
+ alert('Please pick a school from the list.');
+ return;
+ }
+ const exactProgram = prog && progSug.find(p =>
+ (p.program || '').toLowerCase() === prog
+ );
+ if (prog && !exactProgram) {
+ setProgramValid(false);
+ alert('Please pick a program from the list.');
+ return;
+ }
+ const body = normalisePayload({ ...form, tuition: chosenTuition, career_profile_id: careerId, unit_id: selectedUnitId ?? null });
const res = await authFetch('/api/premium/college-profile',{
method:'POST',
headers:{'Content-Type':'application/json'},
@@ -208,96 +289,79 @@ useEffect(() => {
}catch(err){ console.error(err); alert(err.message);}
}
-/* LOAD iPEDS ----------------------------- */
+
+ useEffect(() => {
+ const sch = (form.selected_school || '').trim();
+ const prog = (form.selected_program || '').trim();
+ if (!sch || !prog) { setTypes([]); return; }
+ (async () => {
+ try {
+ const resp = await authFetch(`/api/programs/types?school=${encodeURIComponent(sch)}&program=${encodeURIComponent(prog)}`);
+ const data = resp.ok ? await resp.json() : null;
+ const arr = Array.isArray(data?.types) ? data.types : [];
+ setTypes(arr);
+ } catch { setTypes([]); }
+ })();
+ }, [form.selected_school, form.selected_program]);
+
+ // Resolve UNITID from typed/loaded school name (profile doesn't store unit_id)
+ useEffect(() => {
+ const name = (form.selected_school || '').trim();
+ if (!name || selectedUnitId) return;
+ let cancelled = false;
+ (async () => {
+ try {
+ // try a wider net so exact always shows up
+ const resp = await authFetch(`/api/schools/suggest?query=${encodeURIComponent(name)}&limit=50`);
+ if (!resp.ok) return;
+ const arr = await resp.json();
+ const exact = Array.isArray(arr)
+ ? arr.find(o => (o.name || '').toLowerCase() === name.toLowerCase())
+ : null;
+ if (!cancelled && exact?.unitId) setSelectedUnitId(exact.unitId);
+ } catch {}
+ })();
+ return () => { cancelled = true; };
+ }, [form.selected_school, selectedUnitId]);
+
+
+ // Auto-calc Yearly Tuition via server (parity with Onboarding)
useEffect(() => {
- fetch('/ic2023_ay.csv', { credentials: 'omit' })
- .then(r => r.text())
- .then(text => {
- const rows = text.split('\n').map(l => l.split(','));
- const headers = rows[0];
- const parsed = rows.slice(1).map(r =>
- Object.fromEntries(r.map((v,i)=>[headers[i], v]))
- );
- setIpeds(parsed); // you already declared setIpeds
- })
- .catch(err => console.error('iPEDS load failed', err));
-}, []);
-
- useEffect(() => { fetch('/cip_institution_mapping_new.json', { credentials: 'omit' })
- .then(r=>r.text()).then(t => setCipRows(
- t.split('\n').map(l=>{try{return JSON.parse(l)}catch{ return null }})
- .filter(Boolean)
- ));
- fetch('/ic2023_ay.csv')
- .then(r=>r.text()).then(csv=>{/* identical to CollegeOnboarding */});
-},[]);
-
-useEffect(()=>{
- if(!form.selected_school || !form.selected_program) { setTypes([]); return; }
- const t = cipRows.filter(r =>
- r.INSTNM.toLowerCase()===form.selected_school.toLowerCase() &&
- r.CIPDESC===form.selected_program)
- .map(r=>r.CREDDESC);
- setTypes([...new Set(t)]);
-},[form.selected_school, form.selected_program, cipRows]);
-
-useEffect(() => {
- if (!ipeds.length) return;
- if (!form.selected_school ||
- !form.program_type ||
- !form.credit_hours_per_year) return;
-
- /* 1 ─ locate UNITID */
- const sch = cipRows.find(
- r => r.INSTNM.toLowerCase() === form.selected_school.toLowerCase()
- );
- if (!sch) return;
- const unitId = sch.UNITID;
- const row = ipeds.find(r => r.UNITID === unitId);
- if (!row) return;
-
- /* 2 ─ decide in‑state / district buckets */
- const grad = [
- "Master's Degree","Doctoral Degree",
- "Graduate/Professional Certificate","First Professional Degree"
- ].includes(form.program_type);
-
- const pick = (codeInDist, codeInState, codeOut) => {
- if (form.is_in_district) return row[codeInDist];
- else if (form.is_in_state) return row[codeInState];
- else return row[codeOut];
- };
-
- const partTime = grad
- ? pick('HRCHG5','HRCHG6','HRCHG7')
- : pick('HRCHG1','HRCHG2','HRCHG3');
-
- const fullTime = grad
- ? pick('TUITION5','TUITION6','TUITION7')
- : pick('TUITION1','TUITION2','TUITION3');
-
- const chpy = parseFloat(form.credit_hours_per_year) || 0;
- const est = chpy && chpy < 24
- ? parseFloat(partTime || 0) * chpy
- : parseFloat(fullTime || 0);
-
- setAutoTuition(Math.round(est));
+ (async () => {
+ const chpy = Number(form.credit_hours_per_year);
+ if (!selectedUnitId ||
+ !form.program_type ||
+ !Number.isFinite(chpy) ||
+ chpy <= 0) {
+ // keep previous autoTuition if user has manual override; otherwise show 0
+ if (manualTuition.trim() === '') setAutoTuition(0);
+ return;
+ }
+ try {
+ const qs = new URLSearchParams({
+ unitId: String(selectedUnitId),
+ programType: form.program_type,
+ inState: (form.is_in_state ? 1 : 0).toString(),
+ inDistrict: (form.is_in_district ? 1 : 0).toString(),
+ creditHoursPerYear: String(chpy),
+ }).toString();
+ const resp = await authFetch(`/api/tuition/estimate?${qs}`);
+ const data = resp.ok ? await resp.json() : {};
+ const est = Number.isFinite(data?.estimate) ? data.estimate : 0;
+ if (manualTuition.trim() === '') setAutoTuition(est); // don't clobber manual override
+ } catch {
+ if (manualTuition.trim() === '') setAutoTuition(0);
+ }
+ })();
}, [
- ipeds,
- cipRows,
- form.selected_school,
+ selectedUnitId,
form.program_type,
form.credit_hours_per_year,
form.is_in_state,
- form.is_in_district
+ form.is_in_district,
+ manualTuition // include so clearing manual → auto resumes immediately
]);
-const handleManualTuitionChange = e => setManualTuition(e.target.value);
-const chosenTuition = (() => {
- if (manualTuition.trim() === '') return autoTuition;
- const n = parseFloat(manualTuition);
- return Number.isFinite(n) ? n : autoTuition;
-})();
/* ────────────────────────────────────────────────────────────
Auto‑calculate PROGRAM LENGTH when the user hasn’t typed in
@@ -306,7 +370,7 @@ const chosenTuition = (() => {
useEffect(() => {
if (programLengthTouched) return; // user override
// if a program_length already exists (e.g., from API), don't overwrite it
- if (form.program_length !== '' && form.program_length != null) return; // user override
+ // user override
const chpy = parseFloat(form.credit_hours_per_year);
if (!chpy || chpy <= 0) return;
@@ -341,32 +405,40 @@ const chpy = parseFloat(form.credit_hours_per_year);
programLengthTouched
]);
- useEffect(() => {
+useEffect(() => {
if (graduationTouched) return;
const years = parseFloat(form.program_length);
if (!years || years <= 0) return;
- const start = form.enrollment_date
- ? moment(form.enrollment_date)
- : moment();
+// Mirror Onboarding’s start date selection
+ let start = null;
+ if (form.college_enrollment_status === 'prospective_student') {
+ if (!form.enrollment_date) return; // need user’s chosen start
+ start = moment(form.enrollment_date);
+ } else if (form.college_enrollment_status === 'currently_enrolled') {
+ start = moment().startOf('month').add(1, 'month'); // first of next month
+ } else {
+ // not in-school flows → do nothing
+ return;
+ }
+ const monthsToAdd = Math.round(years * 12);
+ const est = moment(start).add(monthsToAdd, 'months');
+ const iso = firstOfNextMonth(est);
- const iso = start.add(years, 'years')
- .startOf('month')
- .format('YYYY-MM-DD');
setAutoGradDate(iso);
setForm(prev => ({ ...prev, expected_graduation: iso }));
}, [
form.program_length,
form.credit_hours_required,
- form.credit_hours_per_year,
form.hours_completed,
form.credit_hours_per_year,
form.enrollment_date,
graduationTouched
]);
+const handleManualTuitionChange = e => setManualTuition(e.target.value);
return (
@@ -401,22 +473,27 @@ return (
name="selected_school"
value={form.selected_school}
onChange={onSchoolInput}
- onBlur={() => {
- const ok = cipRows.some(
- r => r.INSTNM.toLowerCase() === form.selected_school.toLowerCase()
- );
- setSchoolValid(ok);
- if (!ok) alert('Please pick a school from the list.');
+ onBlur={() => {
+ const trimmed = (form.selected_school || '').trim();
+ const exact = schoolSug.find(o => (o.name || '').toLowerCase() === trimmed.toLowerCase());
+ // Auto-commit exact typed value so downstream lookups work
+ if (exact) {
+ if (form.selected_school !== exact.name) {
+ setForm(prev => ({ ...prev, selected_school: exact.name }));
+ }
+ if (!selectedUnitId) setSelectedUnitId(exact.unitId ?? null);
+ }
+ // Valid if empty (still choosing) OR exact chosen
+ setSchoolValid(trimmed === '' || !!exact);
}}
list="school-suggestions"
+ className={`w-full border rounded p-2 ${
+ (form.selected_school || '').trim() !== '' && !schoolValid ? 'border-red-500' : ''}`}
placeholder="Start typing and choose…"
- className={`w-full border rounded p-2 ${schoolValid ? '' : 'border-red-500'}`}
required
/>
@@ -429,33 +506,40 @@ return (
name="selected_program"
value={form.selected_program}
onChange={onProgramInput}
- onBlur={() => {
- const ok =
- form.selected_school && // need a school first
- cipRows.some(
- r =>
- r.INSTNM.toLowerCase() === form.selected_school.toLowerCase() &&
- r.CIPDESC.toLowerCase() === form.selected_program.toLowerCase()
- );
- setProgramValid(ok);
- if (!ok) alert('Please pick a program from the list.');
- }}
- list="program-suggestions"
+ onBlur={() => {
+ const prog = (form.selected_program || '').trim().toLowerCase();
+ const exact = progSug.find(p => (p.program || '').toLowerCase() === prog);
+ // If user typed an exact program, ensure canonical casing is committed
+ if (exact && form.selected_program !== exact.program) {
+ setForm(prev => ({ ...prev, selected_program: exact.program }));
+ }
+ setProgramValid(prog === '' || !!exact);
+ }}
+ list="program-suggestions"
placeholder="Start typing and choose…"
- className={`w-full border rounded p-2 ${programValid ? '' : 'border-red-500'}`}
+ className={`w-full border rounded p-2 ${
+ (form.selected_program || '').trim() !== '' && !programValid ? 'border-red-500' : '' }`}
required
/>
-
+
{/* 4 │ Program‑type */}
- );
+ })()}
{/* 5 │ Academic calendar */}
diff --git a/src/components/PremiumOnboarding/CollegeOnboarding.js b/src/components/PremiumOnboarding/CollegeOnboarding.js
index b1fdecc..a673d28 100644
--- a/src/components/PremiumOnboarding/CollegeOnboarding.js
+++ b/src/components/PremiumOnboarding/CollegeOnboarding.js
@@ -12,8 +12,8 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
const schoolPrevRef = useRef('');
const [programSuggestions, setProgramSuggestions] = useState([]);
const [availableProgramTypes, setAvailableProgramTypes] = useState([]);
- const [schoolValid, setSchoolValid] = useState(false);
- const [programValid, setProgramValid] = useState(false);
+ const [schoolValid, setSchoolValid] = useState(true);
+ const [programValid, setProgramValid] = useState(true);
const [enrollmentDate, setEnrollmentDate] = useState(
data.enrollment_date || ''
);
@@ -465,6 +465,26 @@ useEffect(() => {
// final handleSubmit => we store chosen tuition + program_length, then move on
const handleSubmit = () => {
+ // enforce “picked from list” at submit time (no blur popups)
+ const schoolText = (selected_school || '').trim();
+ const programText = (selected_program || '').trim();
+ const exactSchool = schoolSuggestions.find(o =>
+ (o.name || '').toLowerCase() === schoolText.toLowerCase()
+ );
+ const exactProgram = programSuggestions.find(p =>
+ (p.program || '').toLowerCase() === programText.toLowerCase()
+ );
+ if (schoolText && !selectedUnitId && !exactSchool) {
+ setSchoolValid(false);
+ alert('Please pick a school from the list.');
+ return;
+ }
+ if (programText && !exactProgram && availableProgramTypes.length === 0) {
+ setProgramValid(false);
+ alert('Please pick a program from the list.');
+ return;
+ }
+
const chosenTuition = manualTuition.trim() === ''
? autoTuition
: parseFloat(manualTuition);
@@ -577,15 +597,19 @@ const ready =
name="selected_school"
value={selected_school}
onChange={handleSchoolChange}
- onBlur={() => {
- const exact = schoolSuggestions.find(o => (o.name || '').toLowerCase() === (selected_school || '').toLowerCase());
- if (exact) handleSchoolSelect(exact); // ensure UNITID is set
- const ok = !!exact || !!selected_school;
- setSchoolValid(ok);
- if (!ok) alert("Please pick a school from the list.");
- }}
+ onBlur={() => {
+ const trimmed = (selected_school || '').trim();
+ const exact = schoolSuggestions.find(o =>
+ (o.name || '').toLowerCase() === trimmed.toLowerCase()
+ );
+ // If exact text was typed, auto-commit so UNITID is set (covers nested/double-select cases)
+ if (exact && !selectedUnitId) handleSchoolSelect(exact);
+ // Valid while empty (still choosing) or when exact is chosen
+ setSchoolValid(trimmed === '' || !!exact);
+ }}
+
list="school-suggestions"
- className={`w-full border rounded p-2 ${schoolValid ? '' : 'border-red-500'}`}
+ className={`w-full border rounded p-2 ${ (selected_school || '').trim() !== '' && !schoolValid ? 'border-red-500' : ''}`}
placeholder="Start typing and choose…"
/>