Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/components/jobs/JobMatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,17 @@ export default function JobMatch() {
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || "Failed to load matches");
// Backend errors come in two shapes:
// { error: "string" } — generic proxy errors
// { error: { code, message, request_id } } — structured product errors (e.g. 503 SERVICE_UNAVAILABLE)
// Read the structured message first so intentional product copy is shown verbatim.
const message =
(errData?.error &&
typeof errData.error === "object" &&
errData.error.message) ||
(typeof errData?.error === "string" && errData.error) ||
"Failed to load matches";
throw new Error(message);
}
const body = (await res.json()) as MatchResponse;
const list = Array.isArray(body.matched_jobs) ? body.matched_jobs : [];
Expand Down
50 changes: 50 additions & 0 deletions src/hooks/use-job-match-availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";

export type JobMatchAvailability = "unknown" | "available" | "unavailable";

/**
* Probes /api/j0di3/jobs/match once to see whether job matching is enabled
* on the backend. The backend returns 503 with code "SERVICE_UNAVAILABLE"
* when the JOB_MATCH_ENABLED flag is off — that's a product-state signal,
* not an error, so we use it to decide whether to render the Matches tab.
*/
export default function useJobMatchAvailability(): JobMatchAvailability {
const [status, setStatus] = useState<JobMatchAvailability>("unknown");

useEffect(() => {
let cancelled = false;

(async () => {
try {
const res = await fetch("/api/j0di3/jobs/match", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});

if (cancelled) return;

if (res.status === 503) {
const body = await res.json().catch(() => null);
const code =
(body?.error && typeof body.error === "object" && body.error.code) ||
body?.code;
if (code === "SERVICE_UNAVAILABLE") {
setStatus("unavailable");
return;
}
}

setStatus("available");
} catch {
if (!cancelled) setStatus("available");
}
})();

return () => {
cancelled = true;
};
}, []);

return status;
}
41 changes: 32 additions & 9 deletions src/pages/jobs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import prisma from "@lib/prisma";
import { GetServerSideProps, NextPage } from "next";
import Link from "next/link";
import { getServerSession } from "next-auth/next";
import React, { useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import JobMatch from "@/components/jobs/JobMatch";
import MockInterview from "@/components/jobs/MockInterview";
import ResumeScorer from "@/components/jobs/ResumeScorer";
import useJobMatchAvailability from "@/hooks/use-job-match-availability";
import { options } from "@/pages/api/auth/options";

type PageProps = {
Expand Down Expand Up @@ -40,6 +41,14 @@ const JobsPage: PageWithLayout = ({ jobs, categories, jobTypes, user }) => {
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("");
const [selectedType, setSelectedType] = useState<string>("");
const matchAvailability = useJobMatchAvailability();
const matchEnabled = matchAvailability !== "unavailable";

useEffect(() => {
if (!matchEnabled && activeTab === "matches") {
setActiveTab("board");
}
}, [matchEnabled, activeTab]);

// Filter jobs based on search and filters
const filteredJobs = useMemo(() => {
Expand Down Expand Up @@ -107,12 +116,26 @@ const JobsPage: PageWithLayout = ({ jobs, categories, jobTypes, user }) => {

{/* Tab Navigation */}
<div className="tw-mb-8 tw-flex tw-gap-1 tw-rounded-lg tw-bg-navy/5 tw-p-1">
{[
{ key: "board" as const, label: "Job Board", icon: "fa-briefcase" },
{ key: "matches" as const, label: "Matches", icon: "fa-bullseye" },
{ key: "resume" as const, label: "Resume Scorer", icon: "fa-file-alt" },
{ key: "interview" as const, label: "Mock Interview", icon: "fa-comments" },
].map((tab) => (
{(
[
{ key: "board" as const, label: "Job Board", icon: "fa-briefcase" },
...(matchEnabled
? [
{
key: "matches" as const,
label: "Matches",
icon: "fa-bullseye",
},
]
: []),
{ key: "resume" as const, label: "Resume Scorer", icon: "fa-file-alt" },
{
key: "interview" as const,
label: "Mock Interview",
icon: "fa-comments",
},
] as { key: JobsTab; label: string; icon: string }[]
).map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
Expand All @@ -128,8 +151,8 @@ const JobsPage: PageWithLayout = ({ jobs, categories, jobTypes, user }) => {
))}
</div>

{/* Resume Scorer Tab */}
{activeTab === "matches" && <JobMatch />}
{/* Matches Tab — only mounted when JOB_MATCH_ENABLED is on */}
{activeTab === "matches" && matchEnabled && <JobMatch />}

{activeTab === "resume" && <ResumeScorer />}

Expand Down
Loading