Skip to content

Commit 4137707

Browse files
rorarclaude
andcommitted
refactor(automations): fetch languages from EURES reference API
Replaces the hardcoded LANGUAGE_OPTIONS + LANGUAGE_NAMES arrays with a live fetch from the EURES reference data API at /shared-data-rest-api/public/reference/languages. ## New API proxy route: /api/eures/languages Follows the same pattern as /api/eures/occupations and /api/eures/locations: - Auth-gated (requires session) - Proxies to https://europa.eu/eures/api/shared-data-rest-api/public/reference/languages - Returns { id, isoCode, label }[] where label is the language name in native script (e.g., "Deutsch", "English", "français") — matching the EURES portal's advanced search UI at europa.eu/eures/portal/jv-se/advanced-search - 24h revalidation cache + stale-while-revalidate: 7d (language list is static) - Sorted alphabetically by native label ## Updated LanguageProficiencyField - Replaced hardcoded arrays with `useEuresLanguages()` hook that fetches from the new proxy route on mount - Language dropdown now shows labels from the EURES API in native script (e.g., "Deutsch (DE)", "English (EN)", "français (FR)") - Shows loading placeholder while the fetch is in-flight - Graceful degradation: empty dropdown on fetch failure (best-effort) - CEFR levels (A1-C2) remain hardcoded — they are a fixed standard Per @rorar's EURES API OpenAPI spec at https://github.com/rorar/EURES-API-Documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 53bbbbe commit 4137707

2 files changed

Lines changed: 140 additions & 32 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { auth } from "@/auth";
2+
import { NextResponse } from "next/server";
3+
4+
const EURES_API_BASE = "https://europa.eu/eures/api";
5+
const LANGUAGES_URL = `${EURES_API_BASE}/shared-data-rest-api/public/reference/languages`;
6+
7+
/**
8+
* EURES reference language list.
9+
*
10+
* Matches the `Language` schema from the EURES OpenAPI spec
11+
* (see `src/lib/connector/job-discovery/modules/eures/generated.ts`):
12+
* { id: number; isoCode: string; label: string }
13+
*
14+
* The `label` field contains the language name in its native script
15+
* (e.g., "Deutsch", "English", "français").
16+
*/
17+
export interface EuresLanguage {
18+
id: number;
19+
isoCode: string;
20+
label: string;
21+
}
22+
23+
export async function GET(): Promise<NextResponse<EuresLanguage[] | { error: string }>> {
24+
const session = await auth();
25+
if (!session?.user) {
26+
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
27+
}
28+
29+
try {
30+
const response = await fetch(LANGUAGES_URL, {
31+
headers: { Accept: "application/json" },
32+
next: { revalidate: 86400 }, // cache for 24h — language list is static
33+
});
34+
35+
if (!response.ok) {
36+
return NextResponse.json(
37+
{ error: `EURES API returned ${response.status}` },
38+
{ status: 502 },
39+
);
40+
}
41+
42+
const data: EuresLanguage[] = await response.json();
43+
44+
// Sort alphabetically by native label for consistent dropdown ordering.
45+
data.sort((a, b) => a.label.localeCompare(b.label));
46+
47+
return NextResponse.json(data, {
48+
headers: {
49+
"Cache-Control": "public, max-age=86400, stale-while-revalidate=604800",
50+
},
51+
});
52+
} catch (error) {
53+
console.error("[eures/languages] Failed to fetch:", error);
54+
return NextResponse.json(
55+
{ error: "Failed to fetch EURES language list" },
56+
{ status: 502 },
57+
);
58+
}
59+
}

src/components/automations/DynamicParamsForm.tsx

Lines changed: 81 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -324,28 +324,47 @@ function MultiselectField({
324324
// Internal: language + CEFR proficiency level selector
325325
// ---------------------------------------------------------------------------
326326

327-
/** ISO 639-1 codes for common European languages. */
328-
const LANGUAGE_OPTIONS = [
329-
"bg", "cs", "da", "de", "el", "en", "es", "et", "fi", "fr",
330-
"ga", "hr", "hu", "it", "lt", "lv", "mt", "nl", "no", "pl",
331-
"pt", "ro", "sk", "sl", "sv",
332-
] as const;
333-
334-
/** CEFR proficiency levels. */
327+
/** CEFR proficiency levels (standard — no API endpoint needed). */
335328
const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"] as const;
336329

337-
/** Native language names for display in the selector. */
338-
const LANGUAGE_NAMES: Record<string, string> = {
339-
bg: "Български (BG)", cs: "Čeština (CS)", da: "Dansk (DA)",
340-
de: "Deutsch (DE)", el: "Ελληνικά (EL)", en: "English (EN)",
341-
es: "Español (ES)", et: "Eesti (ET)", fi: "Suomi (FI)",
342-
fr: "Français (FR)", ga: "Gaeilge (GA)", hr: "Hrvatski (HR)",
343-
hu: "Magyar (HU)", it: "Italiano (IT)", lt: "Lietuvių (LT)",
344-
lv: "Latviešu (LV)", mt: "Malti (MT)", nl: "Nederlands (NL)",
345-
no: "Norsk (NO)", pl: "Polski (PL)", pt: "Português (PT)",
346-
ro: "Română (RO)", sk: "Slovenčina (SK)", sl: "Slovenščina (SL)",
347-
sv: "Svenska (SV)",
348-
};
330+
/** Language entry from the EURES reference API. */
331+
interface EuresLanguage {
332+
id: number;
333+
isoCode: string;
334+
label: string; // native script (e.g., "Deutsch", "English", "français")
335+
}
336+
337+
/**
338+
* Fetches the EURES portal's official language list via our proxy route.
339+
* The route caches for 24h (stale-while-revalidate: 7d), so repeated
340+
* calls within the same session are served from the browser/CDN cache.
341+
*
342+
* Falls back to an empty array on error — the user sees an empty dropdown
343+
* with no crash. This is the "best-effort non-blocking" enrichment pattern
344+
* from CLAUDE.md applied to reference data.
345+
*/
346+
function useEuresLanguages(): EuresLanguage[] {
347+
const [languages, setLanguages] = React.useState<EuresLanguage[]>([]);
348+
349+
React.useEffect(() => {
350+
let cancelled = false;
351+
fetch("/api/eures/languages")
352+
.then((res) => {
353+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
354+
return res.json();
355+
})
356+
.then((data: EuresLanguage[]) => {
357+
if (!cancelled) setLanguages(data);
358+
})
359+
.catch(() => {
360+
// Best-effort: empty list means no language dropdown, which is
361+
// acceptable for a reference-data fetch failure.
362+
});
363+
return () => { cancelled = true; };
364+
}, []);
365+
366+
return languages;
367+
}
349368

350369
interface LanguageProficiencyFieldProps {
351370
field: ConnectorParamField;
@@ -358,9 +377,15 @@ interface LanguageProficiencyFieldProps {
358377
/**
359378
* Structured language + CEFR level selector.
360379
*
361-
* Replaces the old free-text input ("de(B2), en(C1)") with two dropdowns
362-
* + an add button + chip display. The serialized value is the same
363-
* comma-separated format the EURES API expects: "de(B2), en(C1)".
380+
* Languages are fetched from the EURES reference API
381+
* (`/api/eures/languages` → `shared-data-rest-api/public/reference/languages`)
382+
* so the list is authoritative and always up-to-date with the portal.
383+
* Labels are in the language's native script (matching the EURES advanced
384+
* search UI at europa.eu/eures/portal/jv-se/advanced-search).
385+
*
386+
* CEFR levels (A1-C2) are standardized and hardcoded.
387+
*
388+
* Serialized value: same "de(B2), en(C1)" format the EURES search API expects.
364389
*/
365390
function LanguageProficiencyField({
366391
field,
@@ -369,6 +394,8 @@ function LanguageProficiencyField({
369394
displayLabel,
370395
t,
371396
}: LanguageProficiencyFieldProps) {
397+
const allLanguages = useEuresLanguages();
398+
372399
// Parse the serialized string into an array of { lang, level } pairs.
373400
const entries: { lang: string; level: string }[] = (() => {
374401
if (typeof value !== "string" || value.trim() === "") return [];
@@ -392,11 +419,27 @@ function LanguageProficiencyField({
392419
const [pendingLang, setPendingLang] = React.useState("");
393420
const [pendingLevel, setPendingLevel] = React.useState("B2");
394421

422+
// Build a lookup for display names — native script from the API.
423+
const langNameMap = React.useMemo(() => {
424+
const map = new Map<string, string>();
425+
for (const lang of allLanguages) {
426+
map.set(lang.isoCode, lang.label);
427+
}
428+
return map;
429+
}, [allLanguages]);
430+
395431
const usedLanguages = new Set(entries.map((e) => e.lang));
396-
const availableLanguages = LANGUAGE_OPTIONS.filter(
397-
(l) => !usedLanguages.has(l),
432+
const availableLanguages = allLanguages.filter(
433+
(l) => !usedLanguages.has(l.isoCode),
398434
);
399435

436+
const getDisplayName = (isoCode: string) => {
437+
const native = langNameMap.get(isoCode);
438+
return native
439+
? `${native} (${isoCode.toUpperCase()})`
440+
: isoCode.toUpperCase();
441+
};
442+
400443
const addEntry = () => {
401444
if (!pendingLang || !pendingLevel) return;
402445
const next = [...entries, { lang: pendingLang, level: pendingLevel }];
@@ -423,13 +466,13 @@ function LanguageProficiencyField({
423466
className="gap-1.5 pr-1"
424467
>
425468
<span className="text-xs font-medium">
426-
{LANGUAGE_NAMES[entry.lang] ?? entry.lang.toUpperCase()}{entry.level}
469+
{getDisplayName(entry.lang)}{entry.level}
427470
</span>
428471
<button
429472
type="button"
430473
onClick={() => removeEntry(entry.lang)}
431474
className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5"
432-
aria-label={`${t("common.remove")} ${entry.lang.toUpperCase()} ${entry.level}`}
475+
aria-label={`${t("common.remove")} ${getDisplayName(entry.lang)} ${entry.level}`}
433476
>
434477
<X className="h-3 w-3" />
435478
</button>
@@ -439,16 +482,22 @@ function LanguageProficiencyField({
439482
)}
440483

441484
{/* Add row: language select + level select + add button */}
442-
{availableLanguages.length > 0 && (
485+
{(allLanguages.length === 0 || availableLanguages.length > 0) && (
443486
<div className="flex items-center gap-2">
444487
<Select value={pendingLang} onValueChange={setPendingLang}>
445488
<SelectTrigger className="flex-1">
446-
<SelectValue placeholder={t("automations.params.selectLanguage")} />
489+
<SelectValue
490+
placeholder={
491+
allLanguages.length === 0
492+
? t("common.loading")
493+
: t("automations.params.selectLanguage")
494+
}
495+
/>
447496
</SelectTrigger>
448497
<SelectContent>
449498
{availableLanguages.map((lang) => (
450-
<SelectItem key={lang} value={lang}>
451-
{LANGUAGE_NAMES[lang] ?? lang.toUpperCase()}
499+
<SelectItem key={lang.isoCode} value={lang.isoCode}>
500+
{lang.label} ({lang.isoCode.toUpperCase()})
452501
</SelectItem>
453502
))}
454503
</SelectContent>
@@ -470,7 +519,7 @@ function LanguageProficiencyField({
470519
<button
471520
type="button"
472521
onClick={addEntry}
473-
disabled={!pendingLang}
522+
disabled={!pendingLang || allLanguages.length === 0}
474523
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-3"
475524
>
476525
{t("common.add")}

0 commit comments

Comments
 (0)