@@ -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). */
335328const 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
350369interface 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 */
365390function 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