diff --git a/src/app.css b/src/app.css index 3c253e7..947df98 100644 --- a/src/app.css +++ b/src/app.css @@ -4423,7 +4423,7 @@ body { .back-top { position: fixed; right: 18px; - bottom: 18px; + bottom: 90px; z-index: 80; width: 46px; height: 46px; diff --git a/src/components/AiChatWidget.tsx b/src/components/AiChatWidget.tsx index c50145b..06a7450 100644 --- a/src/components/AiChatWidget.tsx +++ b/src/components/AiChatWidget.tsx @@ -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() { AI Assistant { e.currentTarget.style.display = 'none'; }} />

@@ -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) => ( ))}

diff --git a/src/components/dashboard/JobSeekerJobsPage.tsx b/src/components/dashboard/JobSeekerJobsPage.tsx index 4dae4d0..c732859 100644 --- a/src/components/dashboard/JobSeekerJobsPage.tsx +++ b/src/components/dashboard/JobSeekerJobsPage.tsx @@ -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(null); + const [coverLetter, setCoverLetter] = createSignal(null); + const [aiRemaining, setAiRemaining] = createSignal(5); + const [aiLimit, setAiLimit] = createSignal(5); const availableTags = createMemo(() => { const tags = new Set(); @@ -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() { + + +
+

+ Cover Letter Ready +

+

{coverLetter()?.substring(0, 200)}...

+ +
+
)} + + ); } diff --git a/src/components/dashboard/MyDashboardPage.tsx b/src/components/dashboard/MyDashboardPage.tsx index 5cc6436..4aeb40c 100644 --- a/src/components/dashboard/MyDashboardPage.tsx +++ b/src/components/dashboard/MyDashboardPage.tsx @@ -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 = { - 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 any> = portfolio: PortfolioWidget, profile_status: ProfileCompletionWidget, verification_status: VerificationWidget, + ai_usage: AiUsageWidget, }; export default function MyDashboardPage(props: Props) { diff --git a/src/components/dashboard/widgets/AiUsageWidget.tsx b/src/components/dashboard/widgets/AiUsageWidget.tsx new file mode 100644 index 0000000..9fbeb8c --- /dev/null +++ b/src/components/dashboard/widgets/AiUsageWidget.tsx @@ -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 { + 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 ( + } + > +
+
+
+ Plan + + {usage()?.plan || 'Free'} + +
+
+ +
+

+ Monthly +

+

+ {usage()?.monthly_remaining ?? '—'} +

+
+
80 ? '#EF4444' : '#10B981' }} /> +
+

+ of {usage()?.monthly_limit ?? 50} +

+
+ +
+

+ Daily +

+

+ {usage()?.daily_limit !== undefined && usage()?.daily_used !== undefined + ? Math.max(0, (usage()?.daily_limit ?? 10) - (usage()?.daily_used ?? 0)) + : '—'} +

+
+
80 ? '#EF4444' : '#3B82F6' }} /> +
+

+ of {usage()?.daily_limit ?? 10} +

+
+ +
+
+ + Add-on + + {usage()?.addon_balance ?? 0} + +
+
+ + Renews + + {formatDate(usage()?.renewal_date ?? null)} + +
+
+
+ + ); +}