Merge branch 'high-performance' of https://github.com/Traceworks2023/nxtgauge-frontend-solid into high-performance
This commit is contained in:
commit
b5d286ada3
5 changed files with 292 additions and 18 deletions
|
|
@ -4423,7 +4423,7 @@ body {
|
|||
.back-top {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
bottom: 90px;
|
||||
z-index: 80;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ interface ChatMessage {
|
|||
}
|
||||
|
||||
interface ChatResponse {
|
||||
message: string;
|
||||
reply: string;
|
||||
conversation_id: string;
|
||||
intent: string;
|
||||
confidence: number;
|
||||
|
|
@ -35,7 +35,7 @@ export function AiChatWidget() {
|
|||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"Hi! I'm your AI assistant. I can help you create support tickets, fill out forms, generate job descriptions, or write cover letters. What can I help you with?",
|
||||
"Hi! I'm Ask Ash, your Nxtgauge assistant. I can help you:\n• Search help articles & KB\n• Create support tickets\n• Explain your AI plan & usage\n• Answer questions about the platform",
|
||||
},
|
||||
]);
|
||||
const [input, setInput] = createSignal("");
|
||||
|
|
@ -110,7 +110,7 @@ export function AiChatWidget() {
|
|||
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: data.message,
|
||||
content: data.reply,
|
||||
intent: data.intent,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
|
|
@ -199,7 +199,8 @@ export function AiChatWidget() {
|
|||
<img
|
||||
src="/ai-assistant-logo.png"
|
||||
alt="AI Assistant"
|
||||
style={{ width: "26px", height: "26px", "border-radius": "6px", "object-fit": "contain" }}
|
||||
style={{ width: "26px", height: "26px", "border-radius": "6px", "object-fit": "contain", "background": "transparent" }}
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
<div>
|
||||
<p style={{ margin: 0, color: "#fff", "font-weight": "700", "font-size": "15px" }}>
|
||||
|
|
@ -238,11 +239,16 @@ export function AiChatWidget() {
|
|||
"flex-wrap": "wrap",
|
||||
}}
|
||||
>
|
||||
{["Create Ticket", "Job Description", "Cover Letter", "Fill Form"].map((label) => (
|
||||
{[
|
||||
{ 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) => (
|
||||
<button
|
||||
aria-label={`Quick action: ${label}`}
|
||||
aria-label={`Quick action: ${action.label}`}
|
||||
onClick={() => {
|
||||
setInput(`${label.toLowerCase()}: `);
|
||||
setInput(action.text);
|
||||
}}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
|
|
@ -254,7 +260,7 @@ export function AiChatWidget() {
|
|||
color: "#374151",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@
|
|||
* Endpoints:
|
||||
* GET /api/jobseeker/jobs - List available jobs from companies
|
||||
* POST /api/jobseeker/jobs/:id/apply - Apply for a job
|
||||
* POST /api/ai/generate-cover-letter - AI cover letter generation
|
||||
* Custom data: saved_jobs - Bookmarked jobs stored in profile
|
||||
*/
|
||||
import { For, Show, createMemo, createSignal, onMount } from "solid-js";
|
||||
import { Sparkles, Loader } from "lucide-solid";
|
||||
import { BTN_GHOST, BTN_PRIMARY, CARD, INPUT } from "~/components/DashboardShell";
|
||||
import { readJobSeekerProfile, updateJobSeekerCustomData } from "~/lib/job-seeker-custom-data";
|
||||
|
||||
|
|
@ -102,6 +104,10 @@ export default function JobSeekerJobsPage() {
|
|||
const [activeTag, setActiveTag] = createSignal("");
|
||||
const [msg, setMsg] = createSignal("");
|
||||
const [err, setErr] = createSignal("");
|
||||
const [generatingCover, setGeneratingCover] = createSignal<string | null>(null);
|
||||
const [coverLetter, setCoverLetter] = createSignal<string | null>(null);
|
||||
const [aiRemaining, setAiRemaining] = createSignal(5);
|
||||
const [aiLimit, setAiLimit] = createSignal(5);
|
||||
|
||||
const availableTags = createMemo(() => {
|
||||
const tags = new Set<string>();
|
||||
|
|
@ -146,14 +152,16 @@ export default function JobSeekerJobsPage() {
|
|||
void loadSavedJobs();
|
||||
});
|
||||
|
||||
const applyJob = async (jobId: string) => {
|
||||
const applyJob = async (jobId: string, generatedCoverLetter?: string | null) => {
|
||||
setBusyId(jobId);
|
||||
setMsg("");
|
||||
setErr("");
|
||||
try {
|
||||
const res = await apiFetch(`/api/jobseeker/jobs/${jobId}/apply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
body: JSON.stringify({
|
||||
cover_letter: generatedCoverLetter || coverLetter() || undefined
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
|
|
@ -161,6 +169,9 @@ export default function JobSeekerJobsPage() {
|
|||
return;
|
||||
}
|
||||
setMsg("Application submitted successfully.");
|
||||
if (generatedCoverLetter) {
|
||||
setCoverLetter(null);
|
||||
}
|
||||
} catch {
|
||||
setErr("Network error while applying.");
|
||||
} finally {
|
||||
|
|
@ -168,6 +179,48 @@ export default function JobSeekerJobsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const generateCoverLetter = async (job: JobItem) => {
|
||||
if (aiRemaining() <= 0) {
|
||||
setErr("Daily AI generation limit reached. Upgrade to AI Pack for more.");
|
||||
return;
|
||||
}
|
||||
setGeneratingCover(job.id);
|
||||
setErr("");
|
||||
try {
|
||||
const token = window.sessionStorage.getItem("nxtgauge_access_token") || "";
|
||||
const res = await fetch(`${API}/api/ai/generate-cover-letter`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
job_title: job.title || "this position",
|
||||
company_name: job.company_name || undefined,
|
||||
job_description: job.description || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
setErr("Daily AI generation limit reached. Upgrade to AI Pack for more.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok && data.cover_letter) {
|
||||
setCoverLetter(data.cover_letter);
|
||||
setAiRemaining(data.remaining_today ?? aiRemaining() - 1);
|
||||
setAiLimit(data.daily_limit ?? aiLimit());
|
||||
} else {
|
||||
setErr(data.error || "Failed to generate cover letter");
|
||||
}
|
||||
} catch {
|
||||
setErr("Network error during cover letter generation");
|
||||
} finally {
|
||||
setGeneratingCover(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isSaved = (jobId: string) => savedJobs().some((row) => row.id === jobId);
|
||||
|
||||
const toggleSave = async (job: JobItem) => {
|
||||
|
|
@ -393,20 +446,40 @@ export default function JobSeekerJobsPage() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleSave(row)}
|
||||
disabled={busyId() === row.id}
|
||||
disabled={busyId() === row.id || generatingCover() === row.id}
|
||||
style={{
|
||||
...BTN_GHOST,
|
||||
height: "32px",
|
||||
"font-size": "12px",
|
||||
padding: "0 12px",
|
||||
opacity: busyId() === row.id ? "0.7" : "1",
|
||||
opacity: busyId() === row.id || generatingCover() === row.id ? "0.7" : "1",
|
||||
}}
|
||||
>
|
||||
{busyId() === row.id ? "Updating..." : isSaved(row.id) ? "Unsave" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyJob(row.id)}
|
||||
onClick={() => generateCoverLetter(row)}
|
||||
disabled={generatingCover() === row.id || busyId() === row.id || aiRemaining() <= 0}
|
||||
style={{
|
||||
...BTN_GHOST,
|
||||
height: "32px",
|
||||
"font-size": "12px",
|
||||
padding: "0 12px",
|
||||
"border-color": "#FF5E13",
|
||||
color: "#FF5E13",
|
||||
opacity: generatingCover() === row.id || busyId() === row.id || aiRemaining() <= 0 ? "0.7" : "1",
|
||||
}}
|
||||
title="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"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyJob(row.id, coverLetter())}
|
||||
disabled={busyId() === row.id}
|
||||
style={{
|
||||
...BTN_PRIMARY,
|
||||
|
|
@ -419,11 +492,48 @@ export default function JobSeekerJobsPage() {
|
|||
{busyId() === row.id ? "Applying..." : "Apply"}
|
||||
</button>
|
||||
</div>
|
||||
<Show when={coverLetter() && busyId() !== row.id}>
|
||||
<div style={{
|
||||
"margin-top": "8px",
|
||||
padding: "10px",
|
||||
background: "#FFF7ED",
|
||||
border: "1px solid #FFEDD5",
|
||||
"border-radius": "8px",
|
||||
"font-size": "12px",
|
||||
color: "#93410C",
|
||||
}}>
|
||||
<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>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import ShortlistedWidget from './widgets/ShortlistedWidget';
|
|||
import PortfolioWidget from './widgets/PortfolioWidget';
|
||||
import ProfileCompletionWidget from './widgets/ProfileCompletionWidget';
|
||||
import VerificationWidget from './widgets/VerificationWidget';
|
||||
import AiUsageWidget from './widgets/AiUsageWidget';
|
||||
import VerificationSubmissionGuide from './VerificationSubmissionGuide';
|
||||
import { fetchProfile } from '~/lib/api';
|
||||
import {
|
||||
|
|
@ -55,10 +56,10 @@ type Props = {
|
|||
};
|
||||
|
||||
const DEFAULT_WIDGETS: Record<string, string[]> = {
|
||||
PROFESSIONAL: ['tracecoins', 'open_leads', 'my_requests', 'portfolio', 'profile_status', 'verification_status'],
|
||||
COMPANY: ['tracecoins', 'total_jobs', 'applications_received', 'shortlisted_candidates', 'profile_status', 'verification_status'],
|
||||
CUSTOMER: ['credits', 'total_requirements', 'shortlisted_responses'],
|
||||
JOB_SEEKER: ['credits', 'available_jobs', 'my_applications', 'shortlisted', 'profile_status', 'verification_status'],
|
||||
PROFESSIONAL: ['tracecoins', 'open_leads', 'my_requests', 'ai_usage', 'portfolio', 'profile_status', 'verification_status'],
|
||||
COMPANY: ['tracecoins', 'total_jobs', 'applications_received', 'ai_usage', 'shortlisted_candidates', 'profile_status', 'verification_status'],
|
||||
CUSTOMER: ['credits', 'total_requirements', 'ai_usage', 'shortlisted_responses'],
|
||||
JOB_SEEKER: ['credits', 'available_jobs', 'ai_usage', 'my_applications', 'shortlisted', 'profile_status', 'verification_status'],
|
||||
};
|
||||
|
||||
type Metric = {
|
||||
|
|
@ -88,6 +89,7 @@ const WIDGET_COMPONENTS: Record<string, (props: { roleKey: RoleKey }) => any> =
|
|||
portfolio: PortfolioWidget,
|
||||
profile_status: ProfileCompletionWidget,
|
||||
verification_status: VerificationWidget,
|
||||
ai_usage: AiUsageWidget,
|
||||
};
|
||||
|
||||
export default function MyDashboardPage(props: Props) {
|
||||
|
|
|
|||
156
src/components/dashboard/widgets/AiUsageWidget.tsx
Normal file
156
src/components/dashboard/widgets/AiUsageWidget.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { createResource } from 'solid-js';
|
||||
import { Sparkles, Calendar, Zap } from 'lucide-solid';
|
||||
import DashboardWidget from './DashboardWidget';
|
||||
import type { RoleKey } from '../RoleDashboardShared';
|
||||
import { ROLE_PREFIXES } from '../RoleDashboardShared';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
const token =
|
||||
typeof window !== 'undefined'
|
||||
? window.sessionStorage.getItem('nxtgauge_access_token') || ''
|
||||
: '';
|
||||
const cleanPath = path.startsWith('/api/') ? path.slice(4) : path;
|
||||
return fetch(`${API}${cleanPath}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(opts?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface AiUsageData {
|
||||
plan: string;
|
||||
monthly_limit: number;
|
||||
monthly_used: number;
|
||||
monthly_remaining: number;
|
||||
daily_limit: number;
|
||||
daily_used: number;
|
||||
addon_balance: number;
|
||||
renewal_date: string | null;
|
||||
}
|
||||
|
||||
async function fetchAiUsage(roleKey: RoleKey): Promise<AiUsageData | null> {
|
||||
const prefix = ROLE_PREFIXES[roleKey];
|
||||
if (!prefix) return null;
|
||||
try {
|
||||
const res = await apiFetch(`/api/ai/usage/v2`);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return {
|
||||
plan: data.plan || 'Free',
|
||||
monthly_limit: data.monthly_limit || 50,
|
||||
monthly_used: data.monthly_used || 0,
|
||||
monthly_remaining: data.monthly_remaining || 50,
|
||||
daily_limit: data.daily_limit || 10,
|
||||
daily_used: data.daily_used || 0,
|
||||
addon_balance: data.addon_balance || 0,
|
||||
renewal_date: data.renewal_date || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
roleKey: RoleKey;
|
||||
};
|
||||
|
||||
export default function AiUsageWidget(props: Props) {
|
||||
const [usage] = createResource(() => props.roleKey, fetchAiUsage);
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const monthlyPercent = () => {
|
||||
const u = usage();
|
||||
if (!u || u.monthly_limit === 0) return 0;
|
||||
return Math.round((u.monthly_used / u.monthly_limit) * 100);
|
||||
};
|
||||
|
||||
const dailyPercent = () => {
|
||||
const u = usage();
|
||||
if (!u || u.daily_limit === 0) return 0;
|
||||
return Math.round((u.daily_used / u.daily_limit) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardWidget
|
||||
title="AI Usage"
|
||||
loading={usage.loading}
|
||||
error={usage.error ? 'Failed to load' : undefined}
|
||||
icon={<Sparkles size={16} />}
|
||||
>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '16px' }}>
|
||||
<div style={{ 'grid-column': '1 / -1' }}>
|
||||
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between' }}>
|
||||
<span style={{ 'font-size': '12px', color: '#6B7280' }}>Plan</span>
|
||||
<span style={{ 'font-size': '14px', 'font-weight': '600', color: '#111827' }}>
|
||||
{usage()?.plan || 'Free'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p style={{ margin: '0', 'font-size': '10px', 'text-transform': 'uppercase', 'letter-spacing': '0.05em', color: '#6B7280' }}>
|
||||
Monthly
|
||||
</p>
|
||||
<p style={{ margin: '4px 0 2px', 'font-size': '18px', 'font-weight': '700', color: '#111827' }}>
|
||||
{usage()?.monthly_remaining ?? '—'}
|
||||
</p>
|
||||
<div style={{ height: '4px', background: '#E5E7EB', 'border-radius': '2px', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${monthlyPercent()}%`, height: '100%', background: monthlyPercent() > 80 ? '#EF4444' : '#10B981' }} />
|
||||
</div>
|
||||
<p style={{ margin: '2px 0 0', 'font-size': '10px', color: '#9CA3AF' }}>
|
||||
of {usage()?.monthly_limit ?? 50}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p style={{ margin: '0', 'font-size': '10px', 'text-transform': 'uppercase', 'letter-spacing': '0.05em', color: '#6B7280' }}>
|
||||
Daily
|
||||
</p>
|
||||
<p style={{ margin: '4px 0 2px', 'font-size': '18px', 'font-weight': '700', color: '#111827' }}>
|
||||
{usage()?.daily_limit !== undefined && usage()?.daily_used !== undefined
|
||||
? Math.max(0, (usage()?.daily_limit ?? 10) - (usage()?.daily_used ?? 0))
|
||||
: '—'}
|
||||
</p>
|
||||
<div style={{ height: '4px', background: '#E5E7EB', 'border-radius': '2px', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${dailyPercent()}%`, height: '100%', background: dailyPercent() > 80 ? '#EF4444' : '#3B82F6' }} />
|
||||
</div>
|
||||
<p style={{ margin: '2px 0 0', 'font-size': '10px', color: '#9CA3AF' }}>
|
||||
of {usage()?.daily_limit ?? 10}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ 'grid-column': '1 / -1', display: 'flex', 'align-items': 'center', gap: '16px', 'margin-top': '4px' }}>
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '6px' }}>
|
||||
<Zap size={12} style={{ color: '#F59E0B' }} />
|
||||
<span style={{ 'font-size': '11px', color: '#6B7280' }}>Add-on</span>
|
||||
<span style={{ 'font-size': '13px', 'font-weight': '600', color: '#111827' }}>
|
||||
{usage()?.addon_balance ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '6px' }}>
|
||||
<Calendar size={12} style={{ color: '#6B7280' }} />
|
||||
<span style={{ 'font-size': '11px', color: '#6B7280' }}>Renews</span>
|
||||
<span style={{ 'font-size': '13px', 'font-weight': '600', color: '#111827' }}>
|
||||
{formatDate(usage()?.renewal_date ?? null)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardWidget>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue