fix: ESLint, SolidJS reactivity bugs, role workflow bug fixes
All checks were successful
build-and-release / build (push) Successful in 2m16s
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:
parent
9f1448ee1e
commit
40e79f2d01
31 changed files with 3768 additions and 5699 deletions
|
|
@ -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
5145
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
28
src/app.css
28
src/app.css
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
const c = lineCoords(edge);
|
||||
if (!c) return null;
|
||||
const visible = index < edgeRevealCount();
|
||||
const drawing = visible && index === edgeRevealCount() - 1;
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<For each={EDGES}>
|
||||
{(edge, getIndex) => {
|
||||
const c = lineCoords(edge);
|
||||
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}
|
||||
/>
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCoverLetter(null)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
"font-size": "11px",
|
||||
color: "#9CA3AF",
|
||||
cursor: "pointer",
|
||||
padding: "0",
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{/* 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={() => toggleExpanded(row.id)}
|
||||
style={{
|
||||
background: "none", border: "none",
|
||||
"font-size": "11px", color: "#EA580C",
|
||||
cursor: "pointer", padding: "0", "font-weight": "600",
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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,24 +1021,29 @@ 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) => (
|
||||
<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>
|
||||
</Show>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '11px', 'font-weight': '600', 'text-transform': 'uppercase', 'letter-spacing': '0.05em', color: '#9CA3AF' }}>{pkg.name || 'Service'}</p>
|
||||
<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 => (
|
||||
<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>
|
||||
))}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<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>
|
||||
</Show>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '11px', 'font-weight': '600', 'text-transform': 'uppercase', 'letter-spacing': '0.05em', color: '#9CA3AF' }}>{pkg.name || 'Service'}</p>
|
||||
<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}>
|
||||
<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' } : {}) }}>
|
||||
<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>
|
||||
))}
|
||||
{(() => {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,18 +75,22 @@ 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(() => ({}));
|
||||
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,
|
||||
});
|
||||
// Backend returns { settings: { ... } } — unwrap it
|
||||
const s = data.settings ?? data;
|
||||
if (s) {
|
||||
setAutoApply({
|
||||
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,173 +372,162 @@ 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>
|
||||
|
||||
{/* 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>
|
||||
<span style={{ 'font-size': '13px', color: '#374151', 'font-weight': '600' }}>Enable AI Auto-Apply</span>
|
||||
<p style={{ margin: '2px 0 0', 'font-size': '11px', color: '#9CA3AF' }}>
|
||||
{autoApply().is_enabled ? 'Active — AI will apply to matching jobs' : 'Inactive — no auto-apply will occur'}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setAutoApply(s => ({ ...s, is_enabled: !s.is_enabled }))}
|
||||
style={{
|
||||
width: '44px', height: '24px', 'border-radius': '12px', cursor: 'pointer',
|
||||
background: autoApply().is_enabled ? ORANGE : '#D1D5DB',
|
||||
transition: 'background 0.2s', position: 'relative', 'flex-shrink': 0,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'absolute', top: '3px',
|
||||
left: autoApply().is_enabled ? '23px' : '3px',
|
||||
width: '18px', height: '18px', 'border-radius': '50%',
|
||||
background: '#fff', transition: 'left 0.2s',
|
||||
'box-shadow': '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<Show when={autoApply().is_enabled}>
|
||||
<div style={{ display: 'grid', gap: '14px' }}>
|
||||
{/* Max per day */}
|
||||
<div>
|
||||
<label style={LABEL}>Max applications per day</label>
|
||||
<select
|
||||
value={String(autoApply().max_applications_per_day)}
|
||||
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>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Preferred job titles */}
|
||||
<div>
|
||||
<label style={LABEL}>Preferred Job Titles</label>
|
||||
<div style={{ display: 'flex', gap: '6px', 'margin-bottom': '6px', 'flex-wrap': 'wrap' }}>
|
||||
<For each={autoApply().preferred_titles}>
|
||||
{(tag, i) => (
|
||||
<span style={{ display: 'inline-flex', 'align-items': 'center', gap: '4px', background: '#EFF6FF', color: '#1D4ED8', 'border-radius': '20px', padding: '3px 10px', 'font-size': '12px', 'font-weight': '600' }}>
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag('preferred_titles', i())} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#93C5FD', padding: 0, 'font-size': '14px', 'line-height': 1 }}>×</button>
|
||||
</span>
|
||||
)}
|
||||
</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 }}
|
||||
/>
|
||||
<button type="button" onClick={() => { addTag('preferred_titles', titleInput()); setTitleInput(''); }} style={{ ...BTN_GHOST, 'flex-shrink': 0 }}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preferred locations */}
|
||||
<div>
|
||||
<label style={LABEL}>Preferred Locations</label>
|
||||
<div style={{ display: 'flex', gap: '6px', 'margin-bottom': '6px', 'flex-wrap': 'wrap' }}>
|
||||
<For each={autoApply().preferred_locations}>
|
||||
{(tag, i) => (
|
||||
<span style={{ display: 'inline-flex', 'align-items': 'center', gap: '4px', background: '#F0FDF4', color: '#15803D', 'border-radius': '20px', padding: '3px 10px', 'font-size': '12px', 'font-weight': '600' }}>
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag('preferred_locations', i())} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#86EFAC', padding: 0, 'font-size': '14px', 'line-height': 1 }}>×</button>
|
||||
</span>
|
||||
)}
|
||||
</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 }}
|
||||
/>
|
||||
<button type="button" onClick={() => { addTag('preferred_locations', locationInput()); setLocationInput(''); }} style={{ ...BTN_GHOST, 'flex-shrink': 0 }}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preferred skills */}
|
||||
<div>
|
||||
<label style={LABEL}>Preferred Skills / Keywords</label>
|
||||
<div style={{ display: 'flex', gap: '6px', 'margin-bottom': '6px', 'flex-wrap': 'wrap' }}>
|
||||
<For each={autoApply().preferred_skills}>
|
||||
{(tag, i) => (
|
||||
<span style={{ display: 'inline-flex', 'align-items': 'center', gap: '4px', background: '#FFF7ED', color: '#C2410C', 'border-radius': '20px', padding: '3px 10px', 'font-size': '12px', 'font-weight': '600' }}>
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag('preferred_skills', i())} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#FDB89A', padding: 0, 'font-size': '14px', 'line-height': 1 }}>×</button>
|
||||
</span>
|
||||
)}
|
||||
</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 }}
|
||||
/>
|
||||
<button type="button" onClick={() => { addTag('preferred_skills', skillInput()); setSkillInput(''); }} style={{ ...BTN_GHOST, 'flex-shrink': 0 }}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Salary range */}
|
||||
<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}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
</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 & 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>
|
||||
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '10px', 'margin-top': '14px' }}>
|
||||
<button type="button" onClick={saveAutoApply} disabled={savingAutoApply()} style={{ ...BTN_PRIMARY, opacity: savingAutoApply() ? '0.7' : '1' }}>
|
||||
{savingAutoApply() ? 'Saving...' : 'Save Auto-Apply Settings'}
|
||||
</button>
|
||||
<Show when={autoApplyMsg()}>
|
||||
<span style={{ 'font-size': '12px', color: '#16A34A', 'font-weight': '600' }}>{autoApplyMsg()}</span>
|
||||
{/* ── 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>
|
||||
<span style={{ 'font-size': '13px', color: '#374151', 'font-weight': '600' }}>Enable AI Auto-Apply</span>
|
||||
<p style={{ margin: '2px 0 0', 'font-size': '11px', color: '#9CA3AF' }}>
|
||||
{autoApply().is_enabled ? 'Active — AI will apply to matching jobs' : 'Inactive — no auto-apply will occur'}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setAutoApply(s => ({ ...s, is_enabled: !s.is_enabled }))}
|
||||
style={{
|
||||
width: '44px', height: '24px', 'border-radius': '12px', cursor: 'pointer',
|
||||
background: autoApply().is_enabled ? ORANGE : '#D1D5DB',
|
||||
transition: 'background 0.2s', position: 'relative', 'flex-shrink': 0,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'absolute', top: '3px',
|
||||
left: autoApply().is_enabled ? '23px' : '3px',
|
||||
width: '18px', height: '18px', 'border-radius': '50%',
|
||||
background: '#fff', transition: 'left 0.2s',
|
||||
'box-shadow': '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<Show when={autoApply().is_enabled}>
|
||||
<div style={{ display: 'grid', gap: '14px' }}>
|
||||
{/* Max per day */}
|
||||
<div>
|
||||
<label style={LABEL}>Max applications per day</label>
|
||||
<select
|
||||
value={String(autoApply().max_applications_per_day)}
|
||||
onChange={(e) => setAutoApply(s => ({ ...s, max_applications_per_day: Number(e.currentTarget.value) }))}
|
||||
style={{ ...INPUT, cursor: 'pointer' }}
|
||||
>
|
||||
<For each={[1, 2, 3, 5, 10]}>{n => <option value={String(n)}>{n} per day</option>}</For>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Preferred job titles */}
|
||||
<div>
|
||||
<label style={LABEL}>Preferred Job Titles</label>
|
||||
<div style={{ display: 'flex', gap: '6px', 'margin-bottom': '6px', 'flex-wrap': 'wrap' }}>
|
||||
<For each={autoApply().preferred_titles}>
|
||||
{(tag, i) => (
|
||||
<span style={{ display: 'inline-flex', 'align-items': 'center', gap: '4px', background: '#EFF6FF', color: '#1D4ED8', 'border-radius': '20px', padding: '3px 10px', 'font-size': '12px', 'font-weight': '600' }}>
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag('preferred_titles', i())} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#93C5FD', padding: 0, 'font-size': '14px', 'line-height': 1 }}>×</button>
|
||||
</span>
|
||||
)}
|
||||
</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 }} />
|
||||
<button type="button" onClick={() => { addTag('preferred_titles', titleInput()); setTitleInput(''); }} style={{ ...BTN_GHOST, 'flex-shrink': 0 }}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preferred locations */}
|
||||
<div>
|
||||
<label style={LABEL}>Preferred Locations</label>
|
||||
<div style={{ display: 'flex', gap: '6px', 'margin-bottom': '6px', 'flex-wrap': 'wrap' }}>
|
||||
<For each={autoApply().preferred_locations}>
|
||||
{(tag, i) => (
|
||||
<span style={{ display: 'inline-flex', 'align-items': 'center', gap: '4px', background: '#F0FDF4', color: '#15803D', 'border-radius': '20px', padding: '3px 10px', 'font-size': '12px', 'font-weight': '600' }}>
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag('preferred_locations', i())} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#86EFAC', padding: 0, 'font-size': '14px', 'line-height': 1 }}>×</button>
|
||||
</span>
|
||||
)}
|
||||
</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 }} />
|
||||
<button type="button" onClick={() => { addTag('preferred_locations', locationInput()); setLocationInput(''); }} style={{ ...BTN_GHOST, 'flex-shrink': 0 }}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preferred skills */}
|
||||
<div>
|
||||
<label style={LABEL}>Preferred Skills / Keywords</label>
|
||||
<div style={{ display: 'flex', gap: '6px', 'margin-bottom': '6px', 'flex-wrap': 'wrap' }}>
|
||||
<For each={autoApply().preferred_skills}>
|
||||
{(tag, i) => (
|
||||
<span style={{ display: 'inline-flex', 'align-items': 'center', gap: '4px', background: '#FFF7ED', color: '#C2410C', 'border-radius': '20px', padding: '3px 10px', 'font-size': '12px', 'font-weight': '600' }}>
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag('preferred_skills', i())} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#FDB89A', padding: 0, 'font-size': '14px', 'line-height': 1 }}>×</button>
|
||||
</span>
|
||||
)}
|
||||
</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 }} />
|
||||
<button type="button" onClick={() => { addTag('preferred_skills', skillInput()); setSkillInput(''); }} style={{ ...BTN_GHOST, 'flex-shrink': 0 }}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Salary range */}
|
||||
<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} />
|
||||
</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} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={autoApplyErr()}>
|
||||
<span style={{ 'font-size': '12px', color: '#B91C1C', 'font-weight': '600' }}>{autoApplyErr()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '10px', 'margin-top': '14px' }}>
|
||||
<button type="button" onClick={saveAutoApply} disabled={savingAutoApply()} style={{ ...BTN_PRIMARY, opacity: savingAutoApply() ? '0.7' : '1' }}>
|
||||
{savingAutoApply() ? 'Saving...' : 'Save Auto-Apply Settings'}
|
||||
</button>
|
||||
<Show when={autoApplyMsg()}>
|
||||
<span style={{ 'font-size': '12px', color: '#16A34A', 'font-weight': '600' }}>{autoApplyMsg()}</span>
|
||||
</Show>
|
||||
<Show when={autoApplyErr()}>
|
||||
<span style={{ 'font-size': '12px', color: '#B91C1C', 'font-weight': '600' }}>{autoApplyErr()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
import RuntimeDashboardPage from "../dashboard";
|
||||
|
||||
export default RuntimeDashboardPage;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue