From 031d332a1dc9f03f2029f5cf97fd6941cc929a81 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 14 Jun 2026 18:04:47 +0200 Subject: [PATCH 1/5] 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 --- src/app.css | 2 +- src/components/AiChatWidget.tsx | 22 ++-- .../dashboard/JobSeekerJobsPage.tsx | 120 +++++++++++++++++- 3 files changed, 130 insertions(+), 14 deletions(-) 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 14e1ae8..f3ba42c 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; @@ -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() { AI Assistant { e.currentTarget.style.display = 'none'; }} />

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

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)}...

+ +
+
)} + + ); } From e908792ed9fe2faaec1b22c1218b4178823bf4b1 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Sun, 14 Jun 2026 22:49:07 +0530 Subject: [PATCH 2/5] ci: deploy frontend via github actions and ghcr --- .github/workflows/build-and-deploy-ghcr.yml | 90 +++++++++++++++++++++ .github/workflows/sync-to-forgejo.yml | 40 --------- Dockerfile | 4 +- 3 files changed, 92 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/build-and-deploy-ghcr.yml delete mode 100644 .github/workflows/sync-to-forgejo.yml diff --git a/.github/workflows/build-and-deploy-ghcr.yml b/.github/workflows/build-and-deploy-ghcr.yml new file mode 100644 index 0000000..a509438 --- /dev/null +++ b/.github/workflows/build-and-deploy-ghcr.yml @@ -0,0 +1,90 @@ +name: build-and-deploy-ghcr + +on: + push: + branches: + - main + - high-performance + workflow_dispatch: + +permissions: + contents: read + packages: write + +env: + IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + K8S_NAMESPACE: nxtgauge + DEPLOYMENT_NAME: nxtgauge-frontend-solid + CONTAINER_NAME: frontend-solid + APP_KEY: frontend-solid + GITOPS_REPO: Traceworks2023/nxtgauge-gitops + +jobs: + build-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ secrets.GHCR_USERNAME }} + password: ${{ secrets.DEPLOY_GITHUB_TOKEN }} + + - name: Build and push image + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + platforms: linux/amd64 + tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} + + - name: Configure kubeconfig + run: | + set -euo pipefail + mkdir -p ~/.kube + printf '%s' '${{ secrets.KUBE_CONFIG_DATA }}' | base64 -d > ~/.kube/config + chmod 600 ~/.kube/config + + - name: Install kubectl + uses: azure/setup-kubectl@v4 + + - name: Deploy to Kubernetes + env: + GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }} + GHCR_TOKEN: ${{ secrets.DEPLOY_GITHUB_TOKEN }} + run: | + set -euo pipefail + image_ref="${IMAGE_NAME}@${{ steps.build.outputs.digest }}" + kubectl -n "$K8S_NAMESPACE" create secret docker-registry ghcr-regcred --docker-server=ghcr.io --docker-username="$GHCR_USERNAME" --docker-password="$GHCR_TOKEN" --dry-run=client -o yaml | kubectl apply -f - + kubectl -n "$K8S_NAMESPACE" patch deployment "$DEPLOYMENT_NAME" --type merge -p '{"spec":{"template":{"spec":{"imagePullSecrets":[{"name":"ghcr-regcred"}]}}}}' + kubectl -n "$K8S_NAMESPACE" set image deployment/"$DEPLOYMENT_NAME" "$CONTAINER_NAME"="$image_ref" + kubectl -n "$K8S_NAMESPACE" rollout status deployment/"$DEPLOYMENT_NAME" --timeout=10m + + - name: Sync GitOps release + env: + GITOPS_TOKEN: ${{ secrets.DEPLOY_GITHUB_TOKEN }} + run: | + set -euo pipefail + image_ref="${IMAGE_NAME}@${{ steps.build.outputs.digest }}" + git clone "https://${{ secrets.GHCR_USERNAME }}:${GITOPS_TOKEN}@github.com/${GITOPS_REPO}.git" /tmp/nxtgauge-gitops + cd /tmp/nxtgauge-gitops + ./scripts/set-app-release.sh "$APP_KEY" "$image_ref" + if git diff --quiet; then + echo "GitOps repo already up to date." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add apps scripts/set-app-release.sh + git commit -m "chore(gitops): deploy ${APP_KEY}@${{ github.sha }}" + git push origin HEAD:main diff --git a/.github/workflows/sync-to-forgejo.yml b/.github/workflows/sync-to-forgejo.yml deleted file mode 100644 index 354a407..0000000 --- a/.github/workflows/sync-to-forgejo.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: sync-to-forgejo - -on: - push: - branches: - - main - - high-performance - -jobs: - sync: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Push branch to Forgejo - env: - FORGEJO_SECRET: ${{ secrets.FORGEJO_SECRET || secrets.GITEA_SECRET }} - FORGEJO_OWNER: ${{ secrets.FORGEJO_OWNER || 'ashwin' }} - FORGEJO_USERNAME: ${{ secrets.FORGEJO_USERNAME || secrets.GITEA_USERNAME || 'ashwin' }} - REPO: ${{ github.event.repository.name }} - BRANCH: ${{ github.ref_name }} - run: | - set -euo pipefail - test -n "${FORGEJO_SECRET:-}" || { echo "FORGEJO_SECRET is empty"; exit 1; } - - AUTH="$(printf '%s' "${FORGEJO_USERNAME}:${FORGEJO_SECRET}" | base64 -w0)" - TARGET="https://ci.nxtgauge.com/${FORGEJO_OWNER}/${REPO}.git" - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git remote remove forgejo 2>/dev/null || true - git remote add forgejo "${TARGET}" - - git -c http.extraHeader="AUTHORIZATION: basic ${AUTH}" push forgejo "HEAD:${BRANCH}" --force - git -c http.extraHeader="AUTHORIZATION: basic ${AUTH}" push forgejo --tags --force diff --git a/Dockerfile b/Dockerfile index a912af5..a2a8c5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Multi-stage build with memory optimization -FROM registry.nxtgauge.com/node:20-alpine AS builder +FROM node:20-alpine AS builder WORKDIR /app # Skip browser downloads @@ -29,7 +29,7 @@ ENV NODE_OPTIONS="--max-old-space-size=4096" RUN npm run build # Runtime stage -FROM registry.nxtgauge.com/node:20-alpine +FROM node:20-alpine WORKDIR /app # Copy built output From 752e331126425ed5b06894b3e03e0d29b2ac7d7a Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Sun, 14 Jun 2026 22:57:56 +0530 Subject: [PATCH 3/5] fix(ci): use lowercase ghcr image names --- .github/workflows/build-and-deploy-ghcr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-deploy-ghcr.yml b/.github/workflows/build-and-deploy-ghcr.yml index a509438..1cdb469 100644 --- a/.github/workflows/build-and-deploy-ghcr.yml +++ b/.github/workflows/build-and-deploy-ghcr.yml @@ -12,7 +12,7 @@ permissions: packages: write env: - IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + IMAGE_NAME: ghcr.io/traceworks2023/${{ github.event.repository.name }} K8S_NAMESPACE: nxtgauge DEPLOYMENT_NAME: nxtgauge-frontend-solid CONTAINER_NAME: frontend-solid From cbaff36f8816c0cb0e524df001a3a6f6e3fa4055 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Sun, 14 Jun 2026 23:05:24 +0530 Subject: [PATCH 4/5] fix(ci): deploy via gitops from github actions --- .github/workflows/build-and-deploy-ghcr.yml | 22 --------------------- 1 file changed, 22 deletions(-) diff --git a/.github/workflows/build-and-deploy-ghcr.yml b/.github/workflows/build-and-deploy-ghcr.yml index 1cdb469..f5fefb5 100644 --- a/.github/workflows/build-and-deploy-ghcr.yml +++ b/.github/workflows/build-and-deploy-ghcr.yml @@ -48,28 +48,6 @@ jobs: platforms: linux/amd64 tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} - - name: Configure kubeconfig - run: | - set -euo pipefail - mkdir -p ~/.kube - printf '%s' '${{ secrets.KUBE_CONFIG_DATA }}' | base64 -d > ~/.kube/config - chmod 600 ~/.kube/config - - - name: Install kubectl - uses: azure/setup-kubectl@v4 - - - name: Deploy to Kubernetes - env: - GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }} - GHCR_TOKEN: ${{ secrets.DEPLOY_GITHUB_TOKEN }} - run: | - set -euo pipefail - image_ref="${IMAGE_NAME}@${{ steps.build.outputs.digest }}" - kubectl -n "$K8S_NAMESPACE" create secret docker-registry ghcr-regcred --docker-server=ghcr.io --docker-username="$GHCR_USERNAME" --docker-password="$GHCR_TOKEN" --dry-run=client -o yaml | kubectl apply -f - - kubectl -n "$K8S_NAMESPACE" patch deployment "$DEPLOYMENT_NAME" --type merge -p '{"spec":{"template":{"spec":{"imagePullSecrets":[{"name":"ghcr-regcred"}]}}}}' - kubectl -n "$K8S_NAMESPACE" set image deployment/"$DEPLOYMENT_NAME" "$CONTAINER_NAME"="$image_ref" - kubectl -n "$K8S_NAMESPACE" rollout status deployment/"$DEPLOYMENT_NAME" --timeout=10m - - name: Sync GitOps release env: GITOPS_TOKEN: ${{ secrets.DEPLOY_GITHUB_TOKEN }} From 3b8f75d836792bb3d46bf667387ddae604341019 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 14 Jun 2026 20:28:02 +0200 Subject: [PATCH 5/5] feat: add AI usage widget to user dashboard - Add AiUsageWidget showing: - Current plan - Monthly usage vs limit - Daily usage vs limit - Addon balance - Renewal date - Widget added to all role dashboards --- src/components/dashboard/MyDashboardPage.tsx | 10 +- .../dashboard/widgets/AiUsageWidget.tsx | 156 ++++++++++++++++++ 2 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 src/components/dashboard/widgets/AiUsageWidget.tsx 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)} + +
+
+
+ + ); +}