feat: add cover letter generation and improve AI chat widget

- Add AI cover letter generation button on JobSeekerJobsPage
- Generate cover letters via /api/ai/generate-cover-letter
- Show generated cover letter preview before applying
- Update AI Chat Widget quick actions to focus on support & KB search
- Update welcome message to explain Ask Ash capabilities
This commit is contained in:
Tracewebstudio Dev 2026-06-14 18:04:47 +02:00
parent 491b6725b6
commit 031d332a1d
3 changed files with 130 additions and 14 deletions

View file

@ -4423,7 +4423,7 @@ body {
.back-top {
position: fixed;
right: 18px;
bottom: 18px;
bottom: 90px;
z-index: 80;
width: 46px;
height: 46px;

View file

@ -10,7 +10,7 @@ interface ChatMessage {
}
interface ChatResponse {
message: string;
reply: string;
conversation_id: string;
intent: string;
confidence: number;
@ -22,7 +22,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("");
@ -59,7 +59,7 @@ export function AiChatWidget() {
const assistantMessage: ChatMessage = {
role: "assistant",
content: data.message,
content: data.reply,
intent: data.intent,
};
setMessages((prev) => [...prev, assistantMessage]);
@ -148,7 +148,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" }}>
@ -180,11 +181,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",
@ -196,7 +202,7 @@ export function AiChatWidget() {
color: "#374151",
}}
>
{label}
{action.label}
</button>
))}
</div>

View file

@ -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>
);
}