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() {
{ 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)}
+
+
+
+
+
+ );
+}