"use client"; import { useEffect, useRef, useState } from "react"; import { useI18n } from "@/lib/i18n"; import type { Activity } from "@/lib/schemas"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui"; import { SpeakButton } from "@/components/SpeakButton"; import { ActivityHeader, FeedbackPanel, Hint, OptionTile, PromptCard, type ActivityComponentProps, } from "./shared"; type FillBlankActivity = Extract; const BLANK = /_{2,}/; function normalise(value: string): string { return value .toLowerCase() .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^\p{L}\p{N}\s]/gu, " ") .replace(/\s+/g, " ") .trim(); } function levenshtein(a: string, b: string): number { if (a === b) return 0; if (!a.length || !b.length) return Math.max(a.length, b.length); let previous = Array.from({ length: b.length + 1 }, (_, i) => i); for (let i = 1; i <= a.length; i++) { const current = [i]; for (let j = 1; j <= b.length; j++) { current[j] = Math.min( previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), ); } previous = current; } return previous[b.length]; } /** Lenient comparison: case/punctuation-insensitive, one typo allowed, "/" alternatives. */ export function isFillBlankCorrect(userValue: string, answer: string): boolean { const user = normalise(userValue); if (!user) return false; const candidates = answer .split("/") .map((part) => normalise(part.replace(/\(.*?\)/g, ""))) .filter(Boolean); return candidates.some((candidate) => { if (candidate === user) return true; if (candidate.length >= 5 && user.length >= 4 && levenshtein(candidate, user) <= 1) return true; // accept the answer with/without a trailing unit or article const candidateWords = candidate.split(" "); const userWords = user.split(" "); if (candidateWords.length > 1 && userWords.length === 1) return candidateWords.includes(user); return false; }); } export function FillBlank({ activity, active, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) { const { t } = useI18n(); const [value, setValue] = useState(""); const [chosen, setChosen] = useState(null); const [result, setResult] = useState(null); const inputRef = useRef(null); const hasBlank = BLANK.test(activity.sentence); const [before, after = ""] = hasBlank ? activity.sentence.split(BLANK) : [activity.sentence, ""]; useEffect(() => { if (active && !hasBlank && !activity.options) inputRef.current?.focus(); }, [active, hasBlank, activity.options]); const submit = (answerValue: string) => { if (result !== null) return; const correct = isFillBlankCorrect(answerValue, activity.answer); setChosen(answerValue); setResult(correct); onAnswered(correct, { userAnswer: answerValue }); }; const displayAnswer = chosen ?? value; return ( <>

{before} {activity.options ? ( {displayAnswer || "?"} ) : ( setValue(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !result) { event.preventDefault(); submit(value); } event.stopPropagation(); }} disabled={result !== null} aria-label={t("fill.typeMissing")} placeholder={t("fill.placeholder")} autoComplete="off" autoCapitalize="off" spellCheck={false} className={cn( "mx-1 inline-block rounded-xl border-b-4 bg-white/70 px-2 py-0.5 text-center text-frost-950 outline-none placeholder:text-frost-300 dark:bg-white/10 dark:text-frost-50 dark:placeholder:text-white/30", result === null ? "border-frost-300 focus:border-frost-500" : result ? "border-emerald-400 bg-emerald-50 text-emerald-900 dark:bg-emerald-400/15 dark:text-emerald-100" : "border-rose-400 bg-rose-50 text-rose-900 dark:bg-rose-400/15 dark:text-rose-100", )} style={{ width: `${Math.max(5, (displayAnswer.length || 6) + 2)}ch` }} /> )} {after}

{activity.options ? (
{activity.options.map((option) => { const state = result === null ? "idle" : normalise(option) === normalise(activity.answer) ? "correct" : chosen && normalise(chosen) === normalise(option) && result === false ? "wrong" : "dim"; return ( submit(option)} className="justify-center text-center" > {option} ); })}
) : result === null ? (
) : null} {result === null && !activity.options ? ( {t("fill.hint")} ) : null} {result !== null ? ( {t("fill.expected")} {activity.answer} {!result && chosen ? ( <> {" "} {t("fill.youWrote")} {chosen} ) : null} } /> ) : null} ); }