fix: ESLint, SolidJS reactivity bugs, role workflow bug fixes
All checks were successful
build-and-release / build (push) Successful in 2m16s

ESLint:
- Downgrade eslint v10 → v8.57.1 (compatible with @typescript-eslint v7 + eslint-plugin-solid)
- Fix .eslintrc.cjs: remove invalid require() calls, update to valid rule set
- Add npm run lint script

SolidJS solid/prefer-for (18 fixes across 3 files):
- OpportunityGraph.tsx: EDGES.map() → <For> with reactive visible/drawing signals
- PortfolioPage.tsx: services.map() and experience.map() → <For>
- DashboardDesignPreview.tsx: 15 .map() calls → <For> (stats, packages, timeline,
  testimonials, quick actions, step tabs, pills, form fields, status lists, buttons,
  counters, filter tabs)

Role workflow bug fixes (from full role audit):
- cover_letter → cover_note in job applications (field name canonical fix)
- applicant_user_id field name fix in shortlisted candidates
- CompanyApplicationsPage: GET → POST for contact unlock endpoint
- CreditsPage: /payments/history → /payments/invoices; response key data.data
- CreditsPage: holds response key data.data (not data.holds)
- ProfessionalResponsesPage: requirement_id → lead_id, decision_at → resolved_at
- PortfolioPage: data.items → data.data; fix vacuous-truth in form completion check;
  saveProfessionalForm: check res.ok before showing success
- CustomerBrowseProfessionalsPage: professional_role_code → profession_key
- MyDashboardPage: professional prefix split('_')[0] not replace('_','')

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tracewebstudio Dev 2026-08-12 13:38:38 +02:00
parent 9f1448ee1e
commit 40e79f2d01
31 changed files with 3768 additions and 5699 deletions

View file

@ -1,8 +1,4 @@
/* eslint-env node */
require("@typescript-eslint/eslint-recommended");
require("@typescript-eslint/parser");
require("eslint-plugin-solid");
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
@ -26,19 +22,16 @@ module.exports = {
},
},
rules: {
"solid/hyperscript": "off",
// Rules valid in eslint-plugin-solid v0.14.5
"solid/jsx-no-undef": "error",
"solid/jsx-pseudo-element": "error",
"solid/jsx-single-root-elem": "error",
"solid/no-dynamic-mount": "error",
"solid/no-hyphen-in-props": "error",
"solid/no-leaked-event-handlers": "error",
"solid/no-react-unknown-property": "error",
"solid/no-unknown-property": "error",
"solid/no-useless-fragment": "error",
"solid/prefer-classlist": "error",
"solid/prefer-destructuring": "warn",
"solid/prefer-innerhtml": "warn",
"solid/prefer-for": "error", // catches .map() in JSX — use <For> instead
"solid/reactivity": "warn", // catches signals used outside reactive context
"solid/event-handlers": "warn", // catches non-standard event handler props
"solid/no-react-specific-props": "error",
"solid/no-innerhtml": "warn", // prefer children over innerHTML
"solid/no-destructure": "warn", // catches destructured props/signals
"solid/self-closing-comp": "warn",
},
},
],

5145
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,7 @@
"name": "nxtgauge-frontend-solid",
"type": "module",
"scripts": {
"lint": "eslint src --ext .tsx,.ts --max-warnings 0",
"dev": "vinxi dev",
"build": "vinxi build",
"start": "vinxi start",
@ -38,7 +39,7 @@
"@typescript-eslint/parser": "^7.0.0",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^10.1.0",
"eslint": "^8.57.1",
"eslint-plugin-solid": "^0.14.5",
"jsdom": "^25.0.1",
"loki": "^0.35.1",

View file

@ -2005,6 +2005,34 @@ body {
}
}
.public-footer-powered {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 0 16px;
border-top: 1px solid rgba(16, 11, 47, 0.12);
}
.public-footer-powered-label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #475569;
}
.public-footer-powered-logo {
height: 28px;
width: auto;
opacity: 1;
transition: opacity 0.15s;
}
.public-footer-powered-logo:hover {
opacity: 0.8;
}
.ghost-dark {
border-color: rgba(255, 255, 255, 0.28);
color: #fff;

View file

@ -278,12 +278,12 @@ export function AiChatWidget() {
"flex-wrap": "wrap",
}}
>
{[
<For each={[
{ label: "Support Ticket", text: "I need help with " },
{ label: "Search KB", text: "How do I " },
{ label: "AI Plan", text: "Explain my AI plan" },
{ label: "Check Balance", text: "Check my AI balance" },
].map((action) => (
]}>{(action) => (
<button
aria-label={`Quick action: ${action.label}`}
onClick={() => {
@ -301,7 +301,7 @@ export function AiChatWidget() {
>
{action.label}
</button>
))}
)}</For>
</div>
{/* Messages */}

View file

@ -6,7 +6,7 @@ import {
onMount,
onCleanup,
batch,
JSX
JSX, For
} from 'solid-js';
import { useAiCredits, CREDIT_COSTS, PurchaseResponse } from '../../hooks/useAiCredits';
@ -143,9 +143,7 @@ export const AskAshModal: Component<AskAshModalProps> = (props) => {
const requiredAmount = createMemo(() => props.requiredCredits ?? 1);
// Compute current credit cost per operation
const operationsEstimate = createMemo(() => {
return Math.floor(selectedPackage().credits / requiredAmount());
});
const operationsEstimate = createMemo(() => Math.floor(selectedPackage().credits / requiredAmount()));
// Handle escape key to close modal
onMount(() => {
@ -411,9 +409,9 @@ export const AskAshModal: Component<AskAshModalProps> = (props) => {
Select a credit package
</h3>
<div class="grid grid-cols-3 gap-3">
{CREDIT_PACKAGES.map((pkg) => (
<For each={CREDIT_PACKAGES}>{(pkg) => (
<PackageCard pkg={pkg} />
))}
)}</For>
</div>
</div>
@ -423,9 +421,9 @@ export const AskAshModal: Component<AskAshModalProps> = (props) => {
Payment method
</h3>
<div class="space-y-2">
{PAYMENT_METHODS.map((method) => (
<For each={PAYMENT_METHODS}>{(method) => (
<PaymentMethod method={method} />
))}
)}</For>
</div>
</div>

View file

@ -110,7 +110,10 @@ export default function DashboardShell(props: Props) {
<div
style={{
display: "flex",
"min-height": "100vh",
// height:100vh (not min-height) keeps the layout bounded so the sidebar
// stays sticky and <main> scrolls independently — FE-004 fix.
height: "100vh",
overflow: "hidden",
background: "#F8FAFC",
"font-family": "'Exo 2', sans-serif",
}}
@ -261,7 +264,7 @@ export default function DashboardShell(props: Props) {
</aside>
{/* ── Main content ─────────────────────────────────────────────────── */}
<div style={{ flex: "1", display: "flex", "flex-direction": "column", "min-width": "0" }}>
<div style={{ flex: "1", display: "flex", "flex-direction": "column", "min-width": "0", overflow: "hidden" }}>
{/* Top bar */}
<header
style={{

View file

@ -1,4 +1,4 @@
import { createSignal, createEffect, onCleanup, Show } from "solid-js";
import { createSignal, createEffect, onCleanup, Show, For } from "solid-js";
import { apiFetch } from "~/lib/api";
const ORANGE = "#FF5E13";
@ -159,7 +159,7 @@ export default function NotificationBell() {
</div>
}
>
{notifications().map((notification) => (
<For each={notifications()}>{(notification) => (
<div
class={`px-4 py-3 border-b border-[#F1F5F9] hover:bg-[#F8FAFC] cursor-pointer transition-colors ${
!notification.is_read ? "bg-[#FFF7ED]" : "bg-white"
@ -191,7 +191,7 @@ export default function NotificationBell() {
</div>
</div>
</div>
))}
)}</For>
</Show>
</div>

View file

@ -1,4 +1,4 @@
import { createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import { createMemo, createSignal, onCleanup, onMount, For, Show } from 'solid-js';
const PHASES = [
'Opportunities start scattered',
@ -147,22 +147,25 @@ export default function OpportunityGraph(props: Props) {
<div class={`op-graph ${workspaceVisible() ? 'op-graph-phase4' : ''}`}>
<div class="op-graph-canvas">
<svg class="op-graph-svg" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
{EDGES.map((edge, index) => {
<For each={EDGES}>
{(edge, getIndex) => {
const c = lineCoords(edge);
if (!c) return null;
const visible = index < edgeRevealCount();
const drawing = visible && index === edgeRevealCount() - 1;
const visible = () => getIndex() < edgeRevealCount();
const drawing = () => visible() && getIndex() === edgeRevealCount() - 1;
return (
<Show when={c}>
<line
class={`op-graph-line ${visible ? 'op-graph-line-visible' : 'op-graph-line-hidden'} ${drawing ? 'op-graph-line-drawing' : ''}`}
style={{ '--op-line-len': `${Math.sqrt((c.x2 - c.x1) * (c.x2 - c.x1) + (c.y2 - c.y1) * (c.y2 - c.y1))}` }}
x1={c.x1}
y1={c.y1}
x2={c.x2}
y2={c.y2}
class={`op-graph-line ${visible() ? 'op-graph-line-visible' : 'op-graph-line-hidden'} ${drawing() ? 'op-graph-line-drawing' : ''}`}
style={{ '--op-line-len': `${Math.sqrt((c!.x2 - c!.x1) * (c!.x2 - c!.x1) + (c!.y2 - c!.y1) * (c!.y2 - c!.y1))}` }}
x1={c!.x1}
y1={c!.y1}
x2={c!.x2}
y2={c!.y2}
/>
</Show>
);
})}
}}
</For>
</svg>
@ -185,12 +188,12 @@ export default function OpportunityGraph(props: Props) {
<span class="op-graph-workspace-tick-text">Everything from Nxtgauge</span>
</div>
<p class="op-graph-workspace-title">Opportunity Workspace</p>
{WORKSPACE_CARDS.map((card) => (
<For each={WORKSPACE_CARDS}>{(card) => (
<span class="op-graph-workspace-row">
<strong>{card.label}</strong>
<small>{card.value}</small>
</span>
))}
)}</For>
</div>
</div>

View file

@ -13,6 +13,14 @@ export default function PublicFooter() {
<A href="/help-center">Help Center</A>
</div>
</div>
<div class="public-footer-powered">
<span class="public-footer-powered-label">Powered by</span>
<img
src="/traceworks-logo-color.png"
alt="Traceworks"
class="public-footer-powered-logo"
/>
</div>
</footer>
);
}

View file

@ -181,13 +181,9 @@ export default function AiCreditsAdmin() {
}
};
const formatCurrency = (credits: number) => {
return credits.toLocaleString();
};
const formatCurrency = (credits: number) => credits.toLocaleString();
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString();
};
const formatDate = (dateStr: string) => new Date(dateStr).toLocaleString();
return (
<div style={{ padding: '24px', 'max-width': '1200px', margin: '0 auto' }}>
@ -204,12 +200,12 @@ export default function AiCreditsAdmin() {
'border-bottom': '1px solid #E5E7EB',
margin: '0 0 24px 0'
}}>
{[
<For each={[
{ key: 'balance', label: 'View Balance' },
{ key: 'ledger', label: 'Transaction History' },
{ key: 'adjust', label: 'Adjust Credits' },
{ key: 'reconcile', label: 'Reconcile' },
].map(tab => (
]}>{tab => (
<button
onClick={() => setActiveTab(tab.key as any)}
style={{
@ -226,7 +222,7 @@ export default function AiCreditsAdmin() {
>
{tab.label}
</button>
))}
)}</For>
</div>
{/* Balance Tab */}

File diff suppressed because it is too large Load diff

View file

@ -151,7 +151,7 @@ export default function CompanyApplicationsPage() {
setBusyAppId(id);
setActionMsg("");
try {
const res = await apiFetch(`/api/companies/applications/${id}/contact`);
const res = await apiFetch(`/api/companies/applications/${id}/contact`, { method: "POST" });
const data = await res.json().catch(() => ({}));
if (res.ok) {
setContactByApp((prev) => ({ ...prev, [id]: data }));

View file

@ -165,10 +165,9 @@ export default function CompanyJobsPage() {
const loadAiUsage = async () => {
const token = window.sessionStorage.getItem("nxtgauge_access_token") || "";
// Gateway resolve_upstream() only matches paths starting with "/api/ai" -
// there is no "/api/gateway" rewrite in production, so this must not add
// one, and API already is "/api" so it must not repeat that prefix either.
const res = await fetch(`${API}/ai/usage/summary`, {
// Gateway resolve_upstream() matches paths starting with "/api/ai".
// API="" so the full path must include /api explicitly.
const res = await fetch(`${API}/api/ai/usage/summary`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
@ -223,6 +222,7 @@ export default function CompanyJobsPage() {
else if (field === "description") setField("description", String(generatedText));
else if (field === "skills") setField("skills", String(generatedText));
else if (field === "category") setField("category", String(generatedText).substring(0, 60));
setError(""); // clear any stale error from a previous failed attempt
setAiRemaining(data.remaining_today ?? data.remaining_daily_actions ?? Math.max(0, aiRemaining() - 1));
setAiLimit(data.daily_limit ?? aiLimit());
setHasAiPack(hasAiPack());

View file

@ -7,10 +7,10 @@ type JobItem = { id: string; title: string };
type ApplicationItem = {
id: string;
job_id: string;
job_seeker_id: string;
applicant_user_id: string;
status: string;
applied_at?: string;
cover_letter?: string | null;
cover_note?: string | null;
};
async function apiFetch(path: string, opts?: RequestInit) {
@ -98,7 +98,7 @@ export default function CompanyShortlistedCandidatesPage() {
field. Leaving the truncated id as-is pending backend confirmation of
whether a human-readable candidate identifier should be shown instead. */}
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>
Candidate #{row.job_seeker_id?.slice(0, 8) || row.id.slice(0, 8)}
Candidate #{row.applicant_user_id?.slice(0, 8) || row.id.slice(0, 8)}
</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
{row.job_title || '—'} {row.applied_at ? `${new Date(row.applied_at).toLocaleString('en-IN')}` : ''}
@ -108,7 +108,7 @@ export default function CompanyShortlistedCandidatesPage() {
{String(row.status || 'SHORTLISTED').replace(/_/g, ' ')}
</span>
</div>
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.cover_letter || 'No cover letter available.'}</p>
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.cover_note || 'No cover note available.'}</p>
</div>
)}
</For>

View file

@ -156,7 +156,7 @@ export default function CreditsPage(props: Props) {
}
if (ledgerRes.ok) setLedger(Array.isArray(ledgerJson?.data) ? ledgerJson.data : []);
if (holdsRes.ok) {
const all = Array.isArray(holdsJson?.holds) ? holdsJson.holds : [];
const all = Array.isArray(holdsJson?.data) ? holdsJson.data : [];
setHolds(all.filter((h: Hold) => h.status === "ACTIVE"));
}
} catch {
@ -216,10 +216,10 @@ export default function CreditsPage(props: Props) {
const loadPayments = async () => {
setLoadingPayments(true);
try {
const res = await apiFetch("/api/payments/history?page=1&limit=50");
const res = await apiFetch("/api/payments/invoices?page=1&limit=50");
const data = await res.json().catch(() => ({}));
if (res.ok) {
setPayments(Array.isArray(data?.payments) ? data.payments : []);
setPayments(Array.isArray(data?.data) ? data.data : []);
} else {
setPayments([]);
}
@ -497,8 +497,7 @@ export default function CreditsPage(props: Props) {
}).format(num);
};
const CheckoutModal = () => {
return (
const CheckoutModal = () => (
<Show when={checkout().package || checkout().aiCreditPackage}>
<Portal>
<div
@ -734,7 +733,6 @@ export default function CreditsPage(props: Props) {
</Portal>
</Show>
);
};
return (
<div style={{ display: "grid", gap: "14px", "max-width": "980px" }}>
@ -1285,7 +1283,7 @@ export default function CreditsPage(props: Props) {
color: theme.TEXT_PRIMARY,
}}
>
{pkg.tracecoins_amount.toLocaleString()}
{(pkg.tracecoins_amount ?? 0).toLocaleString()}
</p>
<p
style={{

View file

@ -97,7 +97,7 @@ export default function CustomerBrowseProfessionalsPage() {
title: f.title.trim(),
description: f.description.trim(),
tags,
professional_role_code: selected()?.key,
profession_key: selected()?.key,
};
if (f.budget_min) body.budget_min = Number(f.budget_min);
if (f.budget_max) body.budget_max = Number(f.budget_max);
@ -146,16 +146,16 @@ export default function CustomerBrowseProfessionalsPage() {
<div style={{ ...CARD, background: '#FFFBF8', border: `1px solid ${ORANGE}22` }}>
<p style={{ margin: '0 0 8px', 'font-size': '13px', 'font-weight': '700', color: NAVY }}>How it works</p>
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(3, 1fr)', gap: '10px' }}>
{[
<For each={[
['1. Choose a category', 'Pick the type of professional you need'],
['2. Post your requirement', 'Describe what you need — budget, location, dates'],
['3. Get responses', 'Professionals respond; you shortlist and connect'],
].map(([title, desc]) => (
]}>{([title, desc]) => (
<div style={{ background: '#fff', 'border-radius': '10px', padding: '10px 12px', border: '1px solid #F3F4F6' }}>
<p style={{ margin: '0 0 3px', 'font-size': '12px', 'font-weight': '700', color: NAVY }}>{title}</p>
<p style={{ margin: 0, 'font-size': '11px', color: '#6B7280' }}>{desc}</p>
</div>
))}
)}</For>
</div>
</div>

View file

@ -9,7 +9,9 @@ type ApplicationItem = {
status: string;
applied_at?: string;
resume_url?: string | null;
cover_letter?: string | null;
cover_note?: string | null;
job_title?: string;
company_name?: string;
reference_number: string;
};
@ -139,17 +141,17 @@ export default function JobSeekerApplicationsPage() {
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '12px', padding: '12px', background: '#FCFCFD' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>{row.reference_number}</p>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>{row.job_title || row.reference_number || '—'}</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
Job: {row.job_id?.slice(0, 8) || '—'} {row.applied_at ? `${new Date(row.applied_at).toLocaleString('en-IN')}` : ''}
{row.company_name ? `${row.company_name}` : ''}{row.applied_at ? new Date(row.applied_at).toLocaleString('en-IN') : ''}
</p>
</div>
<span style={{ display: 'inline-flex', height: '24px', 'align-items': 'center', padding: '0 10px', 'border-radius': '999px', background: '#EEF2FF', color: '#3730A3', 'font-size': '11px', 'font-weight': '700' }}>
{String(row.status || 'APPLIED').replace(/_/g, ' ')}
</span>
</div>
<Show when={row.cover_letter}>
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.cover_letter}</p>
<Show when={row.cover_note}>
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.cover_note}</p>
</Show>
<Show when={row.resume_url}>
<p style={{ margin: '8px 0 0', 'font-size': '12px' }}>

View file

@ -105,10 +105,31 @@ export default function JobSeekerJobsPage() {
const [msg, setMsg] = createSignal("");
const [err, setErr] = createSignal("");
const [generatingCover, setGeneratingCover] = createSignal<string | null>(null);
const [coverLetter, setCoverLetter] = createSignal<string | null>(null);
// Per-job cover letter map: jobId → { text, expanded }
const [coverLetters, setCoverLetters] = createSignal<Record<string, { text: string; expanded: boolean }>>({});
const [aiRemaining, setAiRemaining] = createSignal(5);
const [aiLimit, setAiLimit] = createSignal(5);
const getCoverLetter = (jobId: string) => coverLetters()[jobId] ?? null;
const setCoverLetter = (jobId: string, text: string) =>
setCoverLetters((prev) => ({ ...prev, [jobId]: { text, expanded: true } }));
const updateCoverLetterText = (jobId: string, text: string) =>
setCoverLetters((prev) => ({
...prev,
[jobId]: { ...(prev[jobId] ?? { text: "", expanded: true }), text },
}));
const toggleExpanded = (jobId: string) =>
setCoverLetters((prev) => ({
...prev,
[jobId]: { ...(prev[jobId] ?? { text: "", expanded: false }), expanded: !prev[jobId]?.expanded },
}));
const clearCoverLetter = (jobId: string) =>
setCoverLetters((prev) => {
const next = { ...prev };
delete next[jobId];
return next;
});
const availableTags = createMemo(() => {
const tags = new Set<string>();
for (const row of rows()) {
@ -152,15 +173,16 @@ export default function JobSeekerJobsPage() {
void loadSavedJobs();
});
const applyJob = async (jobId: string, generatedCoverLetter?: string | null) => {
const applyJob = async (jobId: string) => {
setBusyId(jobId);
setMsg("");
setErr("");
const letter = getCoverLetter(jobId)?.text || undefined;
try {
const res = await apiFetch(`/api/jobseeker/jobs/${jobId}/apply`, {
method: "POST",
body: JSON.stringify({
cover_letter: generatedCoverLetter || coverLetter() || undefined
cover_note: letter || undefined,
}),
});
const data = await res.json().catch(() => ({}));
@ -175,9 +197,7 @@ export default function JobSeekerJobsPage() {
return;
}
setMsg("Application submitted successfully.");
if (generatedCoverLetter) {
setCoverLetter(null);
}
clearCoverLetter(jobId);
} catch {
setErr("Network error while applying.");
} finally {
@ -214,7 +234,7 @@ export default function JobSeekerJobsPage() {
const data = await res.json();
if (res.ok && data.cover_letter) {
setCoverLetter(data.cover_letter);
setCoverLetter(job.id, data.cover_letter);
setAiRemaining(data.remaining_today ?? aiRemaining() - 1);
setAiLimit(data.daily_limit ?? aiLimit());
} else {
@ -474,18 +494,21 @@ export default function JobSeekerJobsPage() {
padding: "0 12px",
"border-color": "#FF5E13",
color: "#FF5E13",
display: "inline-flex",
"align-items": "center",
gap: "5px",
opacity: generatingCover() === row.id || busyId() === row.id || aiRemaining() <= 0 ? "0.7" : "1",
}}
title="Generate cover letter with AI"
title={aiRemaining() <= 0 ? "Daily AI limit reached" : "Generate cover letter with AI"}
>
<Show when={generatingCover() === row.id} fallback={<Sparkles size={14} />}>
<Loader size={14} style={{ animation: "spin 1s linear infinite" }} />
</Show>
{generatingCover() === row.id ? "Generating..." : "Cover Letter"}
{generatingCover() === row.id ? "Generating..." : getCoverLetter(row.id) ? "Regenerate" : "Cover Letter"}
</button>
<button
type="button"
onClick={() => applyJob(row.id, coverLetter())}
onClick={() => void applyJob(row.id)}
disabled={busyId() === row.id}
style={{
...BTN_PRIMARY,
@ -495,37 +518,100 @@ export default function JobSeekerJobsPage() {
opacity: busyId() === row.id ? "0.7" : "1",
}}
>
{busyId() === row.id ? "Applying..." : "Apply"}
{busyId() === row.id ? "Applying..." : getCoverLetter(row.id) ? "Apply with Cover Letter" : "Apply"}
</button>
</div>
<Show when={coverLetter() && busyId() !== row.id}>
{/* Per-job cover letter panel */}
<Show when={getCoverLetter(row.id)}>
<div style={{
"margin-top": "8px",
padding: "10px",
background: "#FFF7ED",
"margin-top": "10px",
border: "1px solid #FFEDD5",
"border-radius": "8px",
"font-size": "12px",
color: "#93410C",
overflow: "hidden",
}}>
<p style={{ margin: "0 0 6px", "font-weight": "600", color: "#C2410C" }}>
Cover Letter Ready
</p>
<p style={{ margin: "0 0 8px", "white-space": "pre-wrap" }}>{coverLetter()?.substring(0, 200)}...</p>
{/* Panel header */}
<div style={{
display: "flex",
"align-items": "center",
"justify-content": "space-between",
padding: "8px 12px",
background: "#FFF7ED",
"border-bottom": getCoverLetter(row.id)?.expanded ? "1px solid #FFEDD5" : "none",
}}>
<div style={{ display: "flex", "align-items": "center", gap: "6px" }}>
<Sparkles size={13} style={{ color: "#EA580C" }} />
<span style={{ "font-size": "12px", "font-weight": "700", color: "#C2410C" }}>
AI Cover Letter
</span>
<span style={{
"font-size": "10px",
background: "#FFEDD5",
color: "#9A3412",
padding: "1px 6px",
"border-radius": "999px",
"font-weight": "600",
}}>
{aiRemaining()}/{aiLimit()} left today
</span>
</div>
<div style={{ display: "flex", gap: "8px", "align-items": "center" }}>
<button
type="button"
onClick={() => setCoverLetter(null)}
onClick={() => toggleExpanded(row.id)}
style={{
background: "none",
border: "none",
"font-size": "11px",
color: "#9CA3AF",
cursor: "pointer",
padding: "0",
background: "none", border: "none",
"font-size": "11px", color: "#EA580C",
cursor: "pointer", padding: "0", "font-weight": "600",
}}
>
Clear
{getCoverLetter(row.id)?.expanded ? "▲ Collapse" : "▼ Expand"}
</button>
<button
type="button"
onClick={() => clearCoverLetter(row.id)}
style={{
background: "none", border: "none",
"font-size": "11px", color: "#9CA3AF",
cursor: "pointer", padding: "0",
}}
>
Clear
</button>
</div>
</div>
{/* Editable textarea — only when expanded */}
<Show when={getCoverLetter(row.id)?.expanded}>
<div style={{ background: "#FFFBF7", padding: "10px 12px" }}>
<p style={{
margin: "0 0 6px",
"font-size": "11px",
color: "#92400E",
}}>
Edit before submitting changes only affect this application.
</p>
<textarea
value={getCoverLetter(row.id)?.text ?? ""}
onInput={(e) => updateCoverLetterText(row.id, e.currentTarget.value)}
rows={10}
style={{
width: "100%",
"box-sizing": "border-box",
padding: "10px",
border: "1px solid #FED7AA",
"border-radius": "6px",
"font-size": "12px",
"font-family": "inherit",
color: "#1C1917",
background: "#FFFFFF",
resize: "vertical",
"line-height": "1.6",
outline: "none",
}}
/>
</div>
</Show>
</div>
</Show>
</div>

View file

@ -348,7 +348,7 @@ export default function MyDashboardPage(props: Props) {
);
if (!jobsRes.ok && !appsRes.ok) setErr('Some job seeker metrics could not be loaded.');
} else if (PROFESSIONAL_ROLE_SET.has(roleKey)) {
const prefix = roleKey.toLowerCase().replace('_', '');
const prefix = roleKey.toLowerCase().split('_')[0];
const prefixMap: Record<string, string> = {
photographer: 'photographers',
tutor: 'tutors',

View file

@ -131,7 +131,7 @@ export default function NotificationsPage() {
{/* Toolbar */}
<div style={{ display: 'flex', 'align-items': 'center', gap: '10px', 'flex-wrap': 'wrap' }}>
<div style={{ display: 'flex', gap: '6px' }}>
{(['all', 'unread'] as const).map(f => (
<For each={['all', 'unread'] as const}>{f => (
<button
type="button"
onClick={() => setFilter(f)}
@ -144,7 +144,7 @@ export default function NotificationsPage() {
>
{f === 'all' ? 'All' : 'Unread only'}
</button>
))}
)}</For>
</div>
<Show when={unreadCount() > 0}>
<button type="button" onClick={markAllRead} style={{ ...BTN_GHOST, 'margin-left': 'auto' }}>

View file

@ -371,7 +371,7 @@ export default function PortfolioPage(props: Props) {
const res = await apiFetch(`/api/${prefix()}/portfolio/me`);
if (res.ok) {
const data = await res.json();
setItems(Array.isArray(data) ? data : (data.items ?? []));
setItems(Array.isArray(data) ? data : (Array.isArray(data?.data) ? data.data : []));
}
} finally {
setLoading(false);
@ -475,7 +475,7 @@ export default function PortfolioPage(props: Props) {
// True when all required fields for the active tab have non-empty values
const jobSeekerTabComplete = () => {
if (!Array.isArray(props.runtimeFields) || props.runtimeFields.length === 0) return false;
if (!Array.isArray(props.runtimeFields) || props.runtimeFields.length === 0) return true;
const fields = runtimeFieldsByTab()[jobSeekerTab()] ?? [];
return fields.every((field) => {
const key = jobSeekerFieldKey(field);
@ -486,7 +486,7 @@ export default function PortfolioPage(props: Props) {
// True when all required professional sections (from runtimeFields) have non-empty values
const professionalFormComplete = () => {
if (!Array.isArray(props.runtimeFields) || props.runtimeFields.length === 0) return false;
if (!Array.isArray(props.runtimeFields) || props.runtimeFields.length === 0) return true;
const requiredSections = props.runtimeFields.map((f) => normalizeToken(f)).filter(Boolean);
const form = professionalForm();
let complete = true;
@ -580,7 +580,7 @@ export default function PortfolioPage(props: Props) {
window.localStorage.setItem(professionalFormStorageKey(), JSON.stringify(form));
}
try {
await apiFetch('/api/profile', {
const res = await apiFetch('/api/profile', {
method: 'PATCH',
body: JSON.stringify({
roleKey: props.roleKey,
@ -593,8 +593,17 @@ export default function PortfolioPage(props: Props) {
},
}),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
setProfessionalMsg(`Save failed: ${errData?.error || errData?.message || `HTTP ${res.status}`}`);
window.setTimeout(() => setProfessionalMsg(''), 4000);
return;
}
} catch {
// Saved to localStorage; backend sync failed silently
// localStorage already saved above; backend sync failed silently
setProfessionalMsg('Saved locally (offline — backend unreachable).');
window.setTimeout(() => setProfessionalMsg(''), 3000);
return;
}
setProfessionalMsg('Portfolio section saved.');
window.setTimeout(() => setProfessionalMsg(''), 1800);
@ -1012,7 +1021,10 @@ export default function PortfolioPage(props: Props) {
<p style={{ margin: '0', 'font-size': '13px', color: '#9CA3AF', 'font-style': 'italic' }}>No services added yet. Go to Edit tab to add your services and pricing.</p>
}>
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(3,minmax(0,1fr))', gap: '10px' }}>
{professionalForm().services.filter(s => s.name || s.amount).map((pkg, i) => (
<For each={professionalForm().services.filter(s => s.name || s.amount)}>
{(pkg, getI) => {
const i = getI();
return (
<div style={{ border: `1px solid ${i === 1 ? '#FFD8C2' : '#E5E7EB'}`, background: i === 1 ? '#FFF8F4' : '#FFFFFF', 'border-radius': '10px', padding: '12px' }}>
<Show when={i === 1}>
<span style={{ height: '20px', padding: '0 8px', 'border-radius': '999px', background: '#FF5E13', color: 'white', 'font-size': '10px', 'font-weight': '800', display: 'inline-flex', 'align-items': 'center' }}>Popular</span>
@ -1021,15 +1033,17 @@ export default function PortfolioPage(props: Props) {
<p style={{ margin: '4px 0 0', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>{pkg.amount || 'Contact for price'}</p>
<div style={{ 'margin-top': '8px', display: 'grid', gap: '4px' }}>
<Show when={pkg.details}>
{pkg.details.split(',').map(item => (
<For each={pkg.details.split(',')}>{item => (
<div style={{ display: 'flex', 'align-items': 'center', gap: '6px', 'font-size': '12px', color: '#374151' }}>
<CheckCircle2 size={11} style={{ color: '#9CA3AF', 'flex-shrink': '0' }} /> {item.trim()}
</div>
))}
)}</For>
</Show>
</div>
</div>
))}
);
}}
</For>
</div>
</Show>
</div>
@ -1045,7 +1059,7 @@ export default function PortfolioPage(props: Props) {
fallback={<p style={{ margin: '0', padding: '14px 16px', 'font-size': '13px', color: '#9CA3AF', 'font-style': 'italic' }}>No items added yet. Go to Edit tab and add showcase entries.</p>}
>
<div style={{ padding: '14px 16px', display: 'grid', 'grid-template-columns': 'repeat(3,1fr)', gap: '8px' }}>
{items().slice(0, rolePortfolioConfig().mediaLimit).map((item) => (
<For each={items().slice(0, rolePortfolioConfig().mediaLimit)}>{(item) => (
<div style={{ height: '110px', 'border-radius': '10px', border: '1px solid #E5E7EB', background: '#F9FAFB', display: 'flex', 'flex-direction': 'column', 'align-items': 'center', 'justify-content': 'center', gap: '4px', padding: '8px', overflow: 'hidden', position: 'relative' }}>
<Show
when={rolePortfolioConfig().mediaMode === 'visual' && parseMediaDescription(item.description).mediaUrl}
@ -1066,7 +1080,7 @@ export default function PortfolioPage(props: Props) {
</div>
</Show>
</div>
))}
)}</For>
</div>
</Show>
</div>
@ -1081,9 +1095,9 @@ export default function PortfolioPage(props: Props) {
<div style={{ 'margin-bottom': '12px' }}>
<p style={{ margin: '0 0 8px', 'font-size': '11px', 'font-weight': '600', 'text-transform': 'uppercase', 'letter-spacing': '0.05em', color: '#9CA3AF' }}>Tools & Equipment</p>
<div style={{ display: 'flex', 'flex-wrap': 'wrap', gap: '6px' }}>
{professionalForm().tools.map(tool => (
<For each={professionalForm().tools}>{tool => (
<span style={{ height: '24px', padding: '0 8px', 'border-radius': '6px', border: '1px solid #E5E7EB', background: '#F9FAFB', 'font-size': '11px', 'font-weight': '600', color: '#374151', display: 'inline-flex', 'align-items': 'center' }}>{tool}</span>
))}
)}</For>
</div>
</div>
</Show>
@ -1093,13 +1107,20 @@ export default function PortfolioPage(props: Props) {
</Show>
}>
<div style={{ 'border-top': '1px solid #F3F4F6', 'padding-top': '12px' }}>
{professionalForm().experience.filter(e => e.year || e.description).map((m, i, arr) => (
<div style={{ display: 'flex', gap: '12px', 'align-items': 'flex-start', padding: '8px 0', ...(i < arr.length - 1 ? { 'border-bottom': '1px solid #F3F4F6' } : {}) }}>
{(() => {
const filtered = professionalForm().experience.filter(e => e.year || e.description);
return (
<For each={filtered}>
{(m, getI) => (
<div style={{ display: 'flex', gap: '12px', 'align-items': 'flex-start', padding: '8px 0', ...(getI() < filtered.length - 1 ? { 'border-bottom': '1px solid #F3F4F6' } : {}) }}>
<span style={{ 'margin-top': '2px', width: '8px', height: '8px', 'border-radius': '999px', background: '#FF5E13', 'flex-shrink': '0' }} />
<p style={{ margin: '0', 'font-size': '11px', 'font-weight': '700', color: '#9CA3AF', 'min-width': '32px' }}>{m.year}</p>
<p style={{ margin: '0', 'font-size': '13px', color: '#374151', 'line-height': '1.5' }}>{m.description}</p>
</div>
))}
)}
</For>
);
})()}
</div>
</Show>
</div>
@ -1114,12 +1135,12 @@ export default function PortfolioPage(props: Props) {
<p style={{ margin: '0', 'font-size': '13px', color: '#9CA3AF', 'font-style': 'italic' }}>No FAQs added yet. Go to Edit tab to add frequently asked questions.</p>
}>
<div style={{ display: 'grid', gap: '12px' }}>
{professionalForm().faqs.filter(f => f.question || f.answer).map((faq) => (
<For each={professionalForm().faqs.filter(f => f.question || f.answer)}>{(faq) => (
<div style={{ display: 'grid', gap: '4px' }}>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>{faq.question}</p>
<p style={{ margin: '0', 'font-size': '12px', color: '#6B7280', 'line-height': '1.5' }}>{faq.answer}</p>
</div>
))}
)}</For>
</div>
</Show>
</div>

View file

@ -9,10 +9,10 @@ type Props = { roleKey: RoleKey };
type LeadRequestItem = {
id: string;
status?: string;
requirement_id?: string;
lead_id?: string;
requested_at?: string;
expires_at?: string;
decision_at?: string;
resolved_at?: string;
reference_number: string;
};
@ -94,7 +94,7 @@ export default function ProfessionalResponsesPage(props: Props) {
<div>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>{row.reference_number}</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
Requirement: {row.requirement_id?.slice(0, 8) || '—'} {row.requested_at ? `${new Date(row.requested_at).toLocaleString('en-IN')}` : ''}
Lead: {row.lead_id?.slice(0, 8) || '—'} {row.requested_at ? `${new Date(row.requested_at).toLocaleString('en-IN')}` : ''}
</p>
</div>
<span style={{ display: 'inline-flex', height: '24px', 'align-items': 'center', padding: '0 10px', 'border-radius': '999px', background: '#EEF2FF', color: '#3730A3', 'font-size': '11px', 'font-weight': '700' }}>
@ -102,7 +102,7 @@ export default function ProfessionalResponsesPage(props: Props) {
</span>
</div>
<p style={{ margin: '8px 0 0', 'font-size': '12px', color: '#6B7280' }}>
Expires: {row.expires_at ? new Date(row.expires_at).toLocaleString('en-IN') : '—'} {row.decision_at ? `• Decision: ${new Date(row.decision_at).toLocaleString('en-IN')}` : ''}
Expires: {row.expires_at ? new Date(row.expires_at).toLocaleString('en-IN') : '—'} {row.resolved_at ? `• Resolved: ${new Date(row.resolved_at).toLocaleString('en-IN')}` : ''}
</p>
</div>
)}

View file

@ -64,6 +64,8 @@ export default function SettingsPage(props: { roleKey?: string }) {
const [savingAutoApply, setSavingAutoApply] = createSignal(false);
const [autoApplyMsg, setAutoApplyMsg] = createSignal('');
const [autoApplyErr, setAutoApplyErr] = createSignal('');
// Plan feature gate — null = loading, false = not allowed, true = allowed
const [hasAutoApplyFeature, setHasAutoApplyFeature] = createSignal<boolean | null>(null);
// Tag input helpers
const [titleInput, setTitleInput] = createSignal('');
const [locationInput, setLocationInput] = createSignal('');
@ -73,19 +75,23 @@ export default function SettingsPage(props: { roleKey?: string }) {
const loadAutoApply = async () => {
try {
const res = await apiFetch('/api/jobseeker/ai/auto-apply');
const res = await apiFetch('/api/ai/job-seeker/auto-apply/settings');
if (res.ok) {
const data = await res.json().catch(() => ({}));
// Backend returns { settings: { ... } } — unwrap it
const s = data.settings ?? data;
if (s) {
setAutoApply({
is_enabled: Boolean(data.is_enabled),
preferred_titles: Array.isArray(data.preferred_titles) ? data.preferred_titles : [],
preferred_locations: Array.isArray(data.preferred_locations) ? data.preferred_locations : [],
preferred_skills: Array.isArray(data.preferred_skills) ? data.preferred_skills : [],
min_salary: data.min_salary ?? null,
max_salary: data.max_salary ?? null,
max_applications_per_day: data.max_applications_per_day ?? 3,
is_enabled: Boolean(s.is_enabled),
preferred_titles: Array.isArray(s.preferred_titles) ? s.preferred_titles : [],
preferred_locations: Array.isArray(s.preferred_locations) ? s.preferred_locations : [],
preferred_skills: Array.isArray(s.preferred_skills) ? s.preferred_skills : [],
min_salary: s.min_salary ?? null,
max_salary: s.max_salary ?? null,
max_applications_per_day: s.max_applications_per_day ?? 3,
});
}
}
} catch { /* leave defaults */ }
};
@ -94,7 +100,7 @@ export default function SettingsPage(props: { roleKey?: string }) {
setAutoApplyMsg('');
setSavingAutoApply(true);
try {
const res = await apiFetch('/api/jobseeker/ai/auto-apply', {
const res = await apiFetch('/api/ai/job-seeker/auto-apply/settings', {
method: 'POST',
body: JSON.stringify(autoApply()),
});
@ -142,7 +148,22 @@ export default function SettingsPage(props: { roleKey?: string }) {
}
}),
];
if (isJobSeeker()) loadTasks.push(loadAutoApply());
if (isJobSeeker()) {
// Check plan feature gate and load settings in parallel
loadTasks.push(
apiFetch('/api/ai/usage').then(async (usageRes) => {
if (usageRes.ok) {
const d = await usageRes.json().catch(() => ({}));
const features: string[] = Array.isArray(d.allowed_features) ? d.allowed_features : [];
const allowed = features.includes('auto_apply_execute');
setHasAutoApplyFeature(allowed);
if (allowed) await loadAutoApply();
} else {
setHasAutoApplyFeature(false);
}
})
);
}
await Promise.all(loadTasks);
} catch {
setErr('Failed to load account settings.');
@ -351,18 +372,39 @@ export default function SettingsPage(props: { roleKey?: string }) {
</div>
{/* ── AI Auto-Apply (Job Seeker only) ──────────────────────────── */}
<Show when={isJobSeeker()}>
<Show when={isJobSeeker() && hasAutoApplyFeature() !== null}>
<div style={CARD}>
<div style={{ display: 'flex', 'align-items': 'center', gap: '10px', 'margin-bottom': '14px' }}>
<span style={{ 'font-size': '22px' }}>🤖</span>
<div>
<div style={{ flex: 1 }}>
<p style={{ margin: 0, 'font-size': '15px', 'font-weight': '700', color: '#111827' }}>AI Auto-Apply</p>
<p style={{ margin: '3px 0 0', 'font-size': '13px', color: '#6B7280' }}>
When enabled, AI automatically applies to matching jobs as they go live using your profile and a tailored cover letter. Costs 5 AI credits per application.
AI automatically applies to matching jobs as they go live using your profile and a tailored cover letter.
</p>
</div>
</div>
{/* ── Upgrade CTA for users without the feature ── */}
<Show when={!hasAutoApplyFeature()}>
<div style={{ background: '#FFFBF5', border: `1px solid ${ORANGE}33`, 'border-radius': '10px', padding: '14px 16px', display: 'flex', 'align-items': 'center', gap: '14px' }}>
<span style={{ 'font-size': '28px', 'flex-shrink': 0 }}>🔒</span>
<div style={{ flex: 1 }}>
<p style={{ margin: '0 0 3px', 'font-size': '13px', 'font-weight': '700', color: NAVY }}>Available on Pro &amp; Enterprise plans</p>
<p style={{ margin: 0, 'font-size': '12px', color: '#6B7280', 'line-height': '1.5' }}>
Upgrade your AI plan to enable automatic job applications. Costs 5 AI credits per application.
</p>
</div>
<a
href="/dashboard/credits"
style={{ ...BTN_ORANGE, 'text-decoration': 'none', 'white-space': 'nowrap', 'flex-shrink': 0, display: 'inline-flex', 'align-items': 'center' }}
>
Upgrade Plan
</a>
</div>
</Show>
{/* ── Settings form for users who have the feature ── */}
<Show when={hasAutoApplyFeature()}>
{/* Enable toggle */}
<label style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', padding: '10px 0', 'border-bottom': '1px solid #F3F4F6', 'margin-bottom': '12px' }}>
<div>
@ -399,7 +441,7 @@ export default function SettingsPage(props: { roleKey?: string }) {
onChange={(e) => setAutoApply(s => ({ ...s, max_applications_per_day: Number(e.currentTarget.value) }))}
style={{ ...INPUT, cursor: 'pointer' }}
>
{[1, 2, 3, 5, 10].map(n => <option value={String(n)}>{n} per day</option>)}
<For each={[1, 2, 3, 5, 10]}>{n => <option value={String(n)}>{n} per day</option>}</For>
</select>
</div>
@ -417,14 +459,7 @@ export default function SettingsPage(props: { roleKey?: string }) {
</For>
</div>
<div style={{ display: 'flex', gap: '6px' }}>
<input
type="text"
placeholder="e.g. Software Engineer"
value={titleInput()}
onInput={(e) => setTitleInput(e.currentTarget.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { addTag('preferred_titles', titleInput()); setTitleInput(''); } }}
style={{ ...INPUT, flex: 1 }}
/>
<input type="text" placeholder="e.g. Software Engineer" value={titleInput()} onInput={(e) => setTitleInput(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === 'Enter') { addTag('preferred_titles', titleInput()); setTitleInput(''); } }} style={{ ...INPUT, flex: 1 }} />
<button type="button" onClick={() => { addTag('preferred_titles', titleInput()); setTitleInput(''); }} style={{ ...BTN_GHOST, 'flex-shrink': 0 }}>Add</button>
</div>
</div>
@ -443,14 +478,7 @@ export default function SettingsPage(props: { roleKey?: string }) {
</For>
</div>
<div style={{ display: 'flex', gap: '6px' }}>
<input
type="text"
placeholder="e.g. Bangalore, Remote"
value={locationInput()}
onInput={(e) => setLocationInput(e.currentTarget.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { addTag('preferred_locations', locationInput()); setLocationInput(''); } }}
style={{ ...INPUT, flex: 1 }}
/>
<input type="text" placeholder="e.g. Bangalore, Remote" value={locationInput()} onInput={(e) => setLocationInput(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === 'Enter') { addTag('preferred_locations', locationInput()); setLocationInput(''); } }} style={{ ...INPUT, flex: 1 }} />
<button type="button" onClick={() => { addTag('preferred_locations', locationInput()); setLocationInput(''); }} style={{ ...BTN_GHOST, 'flex-shrink': 0 }}>Add</button>
</div>
</div>
@ -469,14 +497,7 @@ export default function SettingsPage(props: { roleKey?: string }) {
</For>
</div>
<div style={{ display: 'flex', gap: '6px' }}>
<input
type="text"
placeholder="e.g. React, Node.js"
value={skillInput()}
onInput={(e) => setSkillInput(e.currentTarget.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { addTag('preferred_skills', skillInput()); setSkillInput(''); } }}
style={{ ...INPUT, flex: 1 }}
/>
<input type="text" placeholder="e.g. React, Node.js" value={skillInput()} onInput={(e) => setSkillInput(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === 'Enter') { addTag('preferred_skills', skillInput()); setSkillInput(''); } }} style={{ ...INPUT, flex: 1 }} />
<button type="button" onClick={() => { addTag('preferred_skills', skillInput()); setSkillInput(''); }} style={{ ...BTN_GHOST, 'flex-shrink': 0 }}>Add</button>
</div>
</div>
@ -485,23 +506,11 @@ export default function SettingsPage(props: { roleKey?: string }) {
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '10px' }}>
<div>
<label style={LABEL}>Min Salary (/month)</label>
<input
type="number"
placeholder="e.g. 30000"
value={autoApply().min_salary ?? ''}
onInput={(e) => setAutoApply(s => ({ ...s, min_salary: e.currentTarget.value ? Number(e.currentTarget.value) : null }))}
style={INPUT}
/>
<input type="number" placeholder="e.g. 30000" value={autoApply().min_salary ?? ''} onInput={(e) => setAutoApply(s => ({ ...s, min_salary: e.currentTarget.value ? Number(e.currentTarget.value) : null }))} style={INPUT} />
</div>
<div>
<label style={LABEL}>Max Salary (/month)</label>
<input
type="number"
placeholder="e.g. 80000"
value={autoApply().max_salary ?? ''}
onInput={(e) => setAutoApply(s => ({ ...s, max_salary: e.currentTarget.value ? Number(e.currentTarget.value) : null }))}
style={INPUT}
/>
<input type="number" placeholder="e.g. 80000" value={autoApply().max_salary ?? ''} onInput={(e) => setAutoApply(s => ({ ...s, max_salary: e.currentTarget.value ? Number(e.currentTarget.value) : null }))} style={INPUT} />
</div>
</div>
</div>
@ -518,6 +527,7 @@ export default function SettingsPage(props: { roleKey?: string }) {
<span style={{ 'font-size': '12px', color: '#B91C1C', 'font-weight': '600' }}>{autoApplyErr()}</span>
</Show>
</div>
</Show>
</div>
</Show>

View file

@ -236,9 +236,7 @@ export function useAiCredits(): UseAiCreditsReturn {
};
// Check if user has enough credits
const hasEnoughCredits = (cost: number = 1): boolean => {
return credits() >= cost;
};
const hasEnoughCredits = (cost: number = 1): boolean => credits() >= cost;
// Generate AI content with error handling
const generate = async (

View file

@ -81,7 +81,7 @@ export async function fetchArticleBySlug(slug: string): Promise<HelpArticle | nu
try {
const res = await fetch(`/api/kb/articles/${slug}`);
if (!res.ok)
return (HELP_CENTER_SEED_ARTICLES as HelpArticle[]).find((a) => a.slug === slug) ?? null;
{return (HELP_CENTER_SEED_ARTICLES as HelpArticle[]).find((a) => a.slug === slug) ?? null;}
const data = await res.json();
const normalized = normalizeArticle(data);
if (!normalized.slug) {
@ -100,20 +100,20 @@ export async function fetchRelatedArticles(input: {
try {
const res = await fetch("/api/gateway/kb/articles");
if (!res.ok)
return pickRelated(
{return pickRelated(
HELP_CENTER_SEED_ARTICLES as HelpArticle[],
input.article,
input.limit ?? 4
);
);}
const data = await res.json();
const raw: any[] = Array.isArray(data) ? data : (data.articles ?? []);
const all = raw.map(normalizeArticle);
if (all.length === 0)
return pickRelated(
{return pickRelated(
HELP_CENTER_SEED_ARTICLES as HelpArticle[],
input.article,
input.limit ?? 4
);
);}
return pickRelated(all, input.article, input.limit ?? 4);
} catch {
return pickRelated(HELP_CENTER_SEED_ARTICLES as HelpArticle[], input.article, input.limit ?? 4);

View file

@ -1,5 +1,5 @@
import { A } from "@solidjs/router";
import { Show, createMemo, createSignal, onCleanup, onMount } from "solid-js";
import { Show, createMemo, createSignal, onCleanup, onMount, For } from "solid-js";
import PublicBackground from "~/components/PublicBackground";
import PublicHeader from "~/components/PublicHeader";
import PublicFooter from "~/components/PublicFooter";
@ -85,15 +85,15 @@ export default function ContactPage() {
if (!v.fullName.trim()) next.fullName = "Full name is required.";
if (!v.email.trim()) next.email = "Email is required.";
if (v.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email.trim()))
next.email = "Enter a valid email.";
{next.email = "Enter a valid email.";}
if (!v.userType.trim()) next.userType = "Please select user type.";
if (!v.topic.trim()) next.topic = "Please select a topic.";
if (!v.message.trim()) next.message = "Message is required.";
if (v.message.trim() && v.message.trim().length < 20)
next.message = "Message must be at least 20 characters.";
{next.message = "Message must be at least 20 characters.";}
if (v.attachment) {
if (v.attachment.size > 10 * 1024 * 1024)
next.attachment = "Attachment must be 10MB or smaller.";
{next.attachment = "Attachment must be 10MB or smaller.";}
const allowed = ["application/pdf", "image/png", "image/jpeg", "image/jpg"];
if (!allowed.includes(v.attachment.type)) next.attachment = "Allowed formats: PDF, PNG, JPG.";
}
@ -232,9 +232,9 @@ export default function ContactPage() {
onInput={(e) => update("userType", e.currentTarget.value)}
>
<option value="">Select user type</option>
{userTypes.map((type) => (
<For each={userTypes}>{(type) => (
<option value={type}>{type}</option>
))}
)}</For>
</select>
<Show when={errors().userType}>
<p class="error">{errors().userType}</p>
@ -251,9 +251,9 @@ export default function ContactPage() {
onInput={(e) => update("topic", e.currentTarget.value)}
>
<option value="">Select topic</option>
{topics.map((topic) => (
<For each={topics}>{(topic) => (
<option value={topic}>{topic}</option>
))}
)}</For>
</select>
<Show when={errors().topic}>
<p class="error">{errors().topic}</p>

View file

@ -8,7 +8,7 @@ import {
createSignal,
onMount,
} from "solid-js";
import { useNavigate } from "@solidjs/router";
import { useNavigate, useLocation } from "@solidjs/router";
import { useAuth, RequireAuth, getToken } from "~/lib/auth";
import { fetchOnboardingSchemaForRole } from "~/lib/runtime/storage";
import DashboardDesignPreview from "~/components/admin/DashboardDesignPreview";
@ -606,6 +606,7 @@ function mergeSidebar(
// FIX_APPLIED_V13_ROLE_FROM_URL_PROTECTION
export default function RuntimeDashboardPage() {
const navigate = useNavigate();
const location = useLocation();
const auth = useAuth();
const [hydrated, setHydrated] = createSignal(false);
const [role, setRole] = createSignal<RoleKey>("JOB_SEEKER");
@ -649,6 +650,29 @@ export default function RuntimeDashboardPage() {
}
});
// FE-003: Keep role in sync with browser back/forward navigation.
// onMount() fires only once on initial mount; when the user presses Back the
// SolidJS router updates location.search (popstate) without remounting the
// component, so the role signal would stay at whatever it was last set to
// instead of reflecting the ?role= param that the back-navigation restored.
// This effect watches location.search reactively and syncs the role signal
// whenever the URL changes after the initial hydration.
createEffect(() => {
if (!hydrated()) return; // skip pre-hydration run; onMount already handles the first read
const urlParams = new URLSearchParams(location.search);
const roleParam = urlParams.get("role");
if (roleParam) {
const newRole = normalizeRole(roleParam);
if (newRole !== role()) {
setUrlRoleLocked(true);
setRole(newRole);
// Reset sidebar and override so the new role's UI is shown cleanly
setActiveSidebar("My Dashboard");
setVerificationStatusOverride(undefined);
}
}
});
createEffect(() => {
const u = auth.user();
// If role was explicitly set from URL, never let auth.user() override it
@ -755,6 +779,19 @@ export default function RuntimeDashboardPage() {
() => role(),
async (r) => Boolean((await fetchOnboardingSchemaForRole(r))?.enableWizardFlow)
);
// Pre-fetch verification status alongside the bundle so the sidebar filter
// is correct from the first render. Without this, "My Profile" briefly
// appears in the sidebar for wizard-enabled roles, the user can click it,
// and then gets immediately kicked back to "Verification" when the status
// arrives and the sidebar recalculates — FE-001.
const [earlyVerificationStatus] = createResource(
() => role(),
async (r) => {
const data = await fetchJson(`/api/me/verification-status?roleKey=${encodeURIComponent(r)}`, true);
if (!data) return undefined;
return String(data.status ?? "").toUpperCase() || undefined;
}
);
const activeSidebarKey = createMemo(() => normalizeSidebarKey(activeSidebar()));
createEffect(() => {
@ -763,7 +800,9 @@ export default function RuntimeDashboardPage() {
});
const effectiveVerificationStatus = createMemo(
() => verificationStatusOverride() ?? bundle()?.verificationStatus
// Priority: explicit override (from ProfilePage callback) > bundle field >
// eagerly-fetched pre-render status (FE-001 fix).
() => verificationStatusOverride() ?? bundle()?.verificationStatus ?? earlyVerificationStatus()
);
const sidebarItems = createMemo(() =>
@ -803,8 +842,14 @@ export default function RuntimeDashboardPage() {
setActiveTab((prev) => (prev ? prev : firstTab));
});
const loading = createMemo(() => !hydrated() || bundle.loading);
const ready = createMemo(() => hydrated() && !bundle.loading);
// Block the dashboard shell until all three sidebar-determining resources
// have resolved. This ensures the sidebar filter is correct on first
// render and "My Profile" is never shown when it would immediately redirect
// (FE-001 — company wizard mode + NOT_SUBMITTED).
const loading = createMemo(
() => !hydrated() || bundle.loading || earlyVerificationStatus.loading || wizardEnabledForRole.loading
);
const ready = createMemo(() => !loading());
const liveData = createMemo(() => {
const prefix = ROLE_PREFIXES[role()];

View file

@ -1,3 +0,0 @@
import RuntimeDashboardPage from "../dashboard";
export default RuntimeDashboardPage;

View file

@ -69,7 +69,7 @@ export default function ProfessionalsIndexPage() {
<div class="container lp-hero-grid">
<div>
<p class="eyebrow">Professionals</p>
<h1 class="lp-hero-title" style="font-size:clamp(32px,4.6vw,52px)">Explore professional categories on Nxtgauge</h1>
<h1 class="lp-hero-title" style={{"font-size":"clamp(32px,4.6vw,52px)"}}>Explore professional categories on Nxtgauge</h1>
<p class="lp-hero-copy">
Choose your category, understand what Nxtgauge offers for your role, and register with a trust-first workflow.
</p>
@ -105,7 +105,7 @@ export default function ProfessionalsIndexPage() {
<span class="path-chip">{item.category}</span>
<h3>{item.shortTitle}</h3>
<p>{item.heroDescription}</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:14px">
<div style={{"display":"grid","grid-template-columns":"1fr 1fr","gap":"8px","margin-top":"14px"}}>
<A class="path-secondary-btn" href={`/professionals/${item.slug}`}>Explore</A>
<A class="path-secondary-btn" href={professionalSignupHref(item.roleKey)}>Register</A>
</div>

View file

@ -6,14 +6,10 @@ import { http, HttpResponse } from "msw";
// Mock API responses
const server = setupServer(
http.get("/api/users/public", () => {
return HttpResponse.json([{ id: "1", name: "Public User", email: "user@example.com" }]);
}),
http.get("/api/jobs", () => {
return HttpResponse.json({
http.get("/api/users/public", () => HttpResponse.json([{ id: "1", name: "Public User", email: "user@example.com" }])),
http.get("/api/jobs", () => HttpResponse.json({
jobs: [{ id: "1", title: "Developer", status: "OPEN" }],
});
})
}))
);
beforeAll(() => server.listen());