Adds the full Next.js app (app/, components/, lib/, scripts/, assets/, docs/, Dockerfile, compose, configs) to version control. Security: - CIPC BizPortal customer-code logins (Login 1 + Login 3) now read CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE / CIPC_LOGIN3_PASSWORD from .env.local instead of source. - BizPortal personal login in apply-bizportal route reads BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD. - Added .env.example as the committed template. .gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright storage_state.json (live CIPC cookies), scratch dirs, client PDFs and debug screenshots.
294 lines
12 KiB
TypeScript
294 lines
12 KiB
TypeScript
// components/VoiceField.tsx
|
|
// Drop-in widget that wraps a textarea with:
|
|
// • push-to-talk mic button (live transcription via Web Speech API)
|
|
// • speak-back 🔊 button (manual narration of the current text)
|
|
// • speak-back mute toggle (persisted to localStorage)
|
|
// • modal symmetry: tracks whether the user is currently in "voice" or
|
|
// "text" mode and notifies the parent via onInputModeChange so a sibling
|
|
// agent-reply component can auto-speak its reply when the user spoke
|
|
//
|
|
// Designed to slot into any Ysterbaard wizard form. Controlled component
|
|
// — value + onChange behave like a normal <textarea>.
|
|
|
|
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useSpeech } from "@/lib/voice/useSpeech";
|
|
import { useVoiceInput } from "@/lib/voice/useVoiceInput";
|
|
|
|
/** How the most recent user input was produced. */
|
|
export type InputMode = "voice" | "text";
|
|
|
|
interface Props {
|
|
/** Current text value (controlled). */
|
|
value: string;
|
|
/** Change handler — receives the full new value. */
|
|
onChange: (next: string) => void;
|
|
/** Optional id for label association. */
|
|
id?: string;
|
|
/** Placeholder text. */
|
|
placeholder?: string;
|
|
/** Optional label rendered above the textarea. */
|
|
label?: string;
|
|
/** Number of visible rows. */
|
|
rows?: number;
|
|
/** BCP-47 lang for both STT and TTS. Defaults to en-ZA. */
|
|
lang?: string;
|
|
/** Optional secondary hint rendered next to the label. */
|
|
hint?: string;
|
|
/** Whether to render the speak-back 🔊 button. Default true. */
|
|
enableSpeakBack?: boolean;
|
|
/** Show a small "speaking…" indicator. Default true. */
|
|
showStatus?: boolean;
|
|
/** Extra className on the wrapping <div>. */
|
|
className?: string;
|
|
/**
|
|
* Fired whenever the input mode flips (voice ↔ text). Lets a parent
|
|
* agent-reply component know whether to auto-speak its reply.
|
|
*/
|
|
onInputModeChange?: (mode: InputMode) => void;
|
|
}
|
|
|
|
const SPEAK_BACK_STORAGE_KEY = "wf:speakBackEnabled";
|
|
|
|
/**
|
|
* VoiceField — a textarea with push-to-talk transcription and optional
|
|
* text-to-speech readout. Modal symmetry is preserved: when the user
|
|
* uses voice, callers can react via onInputModeChange("voice") and
|
|
* auto-speak their reply; when the user types, the same callback fires
|
|
* with "text" and the caller stays silent.
|
|
*
|
|
* Accessibility:
|
|
* • Mic button has aria-label that toggles between "Start voice input"
|
|
* and "Stop voice input"
|
|
* • Speaker button has aria-pressed reflecting its on/off state
|
|
* • Live regions announce errors politely
|
|
*/
|
|
export default function VoiceField({
|
|
value,
|
|
onChange,
|
|
id,
|
|
placeholder,
|
|
label,
|
|
hint,
|
|
rows = 3,
|
|
lang = "en-ZA",
|
|
enableSpeakBack = true,
|
|
showStatus = true,
|
|
className = "",
|
|
onInputModeChange
|
|
}: Props) {
|
|
const voice = useVoiceInput({ lang, append: true });
|
|
const speech = useSpeech({ lang });
|
|
|
|
// Persisted on/off for the speak-back feature.
|
|
const [speakBackEnabled, setSpeakBackEnabled] = useState<boolean>(true);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const stored = window.localStorage.getItem(SPEAK_BACK_STORAGE_KEY);
|
|
if (stored !== null) {
|
|
setSpeakBackEnabled(stored === "1");
|
|
}
|
|
} catch {
|
|
/* SSR / private mode — ignore */
|
|
}
|
|
}, []);
|
|
|
|
// Track the *current* mode — voice flips true on mic start, text flips
|
|
// true on the first keystroke after a voice session. We don't fire the
|
|
// callback on every transient state; we fire when the mode *settles*
|
|
// (when voice.stop is called, or when typing follows silence).
|
|
const [inputMode, setInputMode] = useState<InputMode>("text");
|
|
const lastNotifiedModeRef = useRef<InputMode>("text");
|
|
|
|
// When the user starts listening, flip to voice and notify.
|
|
useEffect(() => {
|
|
if (voice.listening && lastNotifiedModeRef.current !== "voice") {
|
|
lastNotifiedModeRef.current = "voice";
|
|
setInputMode("voice");
|
|
onInputModeChange?.("voice");
|
|
}
|
|
}, [voice.listening, onInputModeChange]);
|
|
|
|
// When the user starts typing (and we're not currently listening),
|
|
// flip back to text and notify.
|
|
const handleChange = useCallback(
|
|
(next: string) => {
|
|
if (!voice.listening && lastNotifiedModeRef.current !== "text") {
|
|
lastNotifiedModeRef.current = "text";
|
|
setInputMode("text");
|
|
onInputModeChange?.("text");
|
|
}
|
|
onChange(next);
|
|
},
|
|
[voice.listening, onChange, onInputModeChange]
|
|
);
|
|
|
|
const toggleSpeakBack = useCallback(() => {
|
|
setSpeakBackEnabled((prev) => {
|
|
const next = !prev;
|
|
try {
|
|
window.localStorage.setItem(SPEAK_BACK_STORAGE_KEY, next ? "1" : "0");
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
if (!next) speech.cancel();
|
|
return next;
|
|
});
|
|
}, [speech]);
|
|
|
|
// Compose live text = persisted text + interim. Interim is *displayed*
|
|
// but not yet committed to onChange — when the user pauses, onresult
|
|
// fires with isFinal and onChange gets called.
|
|
const liveValue = value + (voice.interim ? (value && !value.endsWith(" ") ? " " : "") + voice.interim : "");
|
|
|
|
// Commit the interim/final transcript into the controlled value on demand,
|
|
// e.g. when the user clicks "use transcript" or when the form is submitted.
|
|
const applyVoiceTranscript = useCallback(() => {
|
|
const next = (value ? value.trim() + " " : "") + voice.transcript.trim();
|
|
onChange(next.trim());
|
|
voice.reset();
|
|
}, [value, voice, onChange]);
|
|
|
|
// Manual replay — what 🔊 does. The parent decides whether to auto-fire
|
|
// this when inputMode === "voice" by wiring speak() into its agent reply.
|
|
const replay = useCallback(() => {
|
|
if (value.trim()) speech.speak(value);
|
|
}, [speech, value]);
|
|
|
|
return (
|
|
<div className={className} data-od-input-mode={inputMode}>
|
|
{label && (
|
|
<label htmlFor={id} className="mb-1 block text-sm font-medium text-neutral-700">
|
|
{label}
|
|
{hint && <span className="ml-2 text-xs font-normal text-neutral-500">{hint}</span>}
|
|
{inputMode === "voice" && (
|
|
<span className="ml-2 inline-flex items-center gap-1 rounded-full bg-rose-100 px-2 py-0.5 text-xs font-medium text-rose-700">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-rose-500" /> voice mode
|
|
</span>
|
|
)}
|
|
</label>
|
|
)}
|
|
|
|
<div className="relative">
|
|
<textarea
|
|
id={id}
|
|
value={liveValue}
|
|
onChange={(e) => handleChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
rows={rows}
|
|
className="w-full resize-y rounded-lg border border-neutral-300 bg-white px-3 py-2 pr-24 text-sm shadow-sm focus:border-rose-500 focus:outline-none focus:ring-1 focus:ring-rose-500"
|
|
aria-describedby={id ? `${id}-status` : undefined}
|
|
/>
|
|
|
|
{/* Inline buttons: mic + speaker + mute */}
|
|
<div className="absolute bottom-2 right-2 flex items-center gap-1">
|
|
{voice.supported && (
|
|
<button
|
|
type="button"
|
|
onClick={voice.listening ? voice.stop : voice.start}
|
|
aria-label={voice.listening ? "Stop voice input" : "Start voice input"}
|
|
title={voice.listening ? "Stop voice input" : "Start voice input"}
|
|
className={`flex h-8 w-8 items-center justify-center rounded-full transition ${
|
|
voice.listening
|
|
? "bg-rose-500 text-white animate-pulse"
|
|
: "bg-neutral-100 text-neutral-700 hover:bg-neutral-200"
|
|
}`}
|
|
>
|
|
{/* mic icon */}
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<rect x="9" y="2" width="6" height="12" rx="3" />
|
|
<path d="M19 10a7 7 0 0 1-14 0" />
|
|
<line x1="12" y1="17" x2="12" y2="22" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
|
|
{enableSpeakBack && speech.supported && value.trim().length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={replay}
|
|
aria-label={speech.speaking ? "Stop speaking" : "Speak this text aloud"}
|
|
title={speech.speaking ? "Stop speaking" : "Speak this text aloud"}
|
|
className={`flex h-8 w-8 items-center justify-center rounded-full transition ${
|
|
speech.speaking
|
|
? "bg-neutral-900 text-white"
|
|
: "bg-neutral-100 text-neutral-700 hover:bg-neutral-200"
|
|
}`}
|
|
>
|
|
{/* speaker icon */}
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
|
|
{enableSpeakBack && (
|
|
<button
|
|
type="button"
|
|
onClick={toggleSpeakBack}
|
|
aria-pressed={speakBackEnabled}
|
|
aria-label={speakBackEnabled ? "Mute speak-back" : "Unmute speak-back"}
|
|
title={speakBackEnabled ? "Speak-back on" : "Speak-back off"}
|
|
className={`flex h-8 w-8 items-center justify-center rounded-full transition ${
|
|
speakBackEnabled
|
|
? "bg-emerald-100 text-emerald-700"
|
|
: "bg-neutral-100 text-neutral-400"
|
|
}`}
|
|
>
|
|
{speakBackEnabled ? (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="M11 5 6 9H2v6h4l5 4V5z" />
|
|
<line x1="22" y1="9" x2="16" y2="15" />
|
|
<line x1="16" y1="9" x2="22" y2="15" />
|
|
</svg>
|
|
) : (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="M11 5 6 9H2v6h4l5 4V5z" />
|
|
<line x1="23" y1="9" x2="17" y2="15" />
|
|
<line x1="17" y1="9" x2="23" y2="15" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{showStatus && (
|
|
<div id={id ? `${id}-status` : undefined} className="mt-1 text-xs text-neutral-500" aria-live="polite">
|
|
{voice.listening && <span className="text-rose-600">● listening — speak now…</span>}
|
|
{!voice.listening && voice.transcript && (
|
|
<span>
|
|
captured: <span className="italic">“{voice.transcript}”</span>{" "}
|
|
<button
|
|
type="button"
|
|
onClick={applyVoiceTranscript}
|
|
className="ml-1 text-rose-600 hover:underline"
|
|
>
|
|
apply to field
|
|
</button>
|
|
<button type="button" onClick={voice.reset} className="ml-2 text-neutral-500 hover:underline">
|
|
discard
|
|
</button>
|
|
</span>
|
|
)}
|
|
{!voice.listening && speech.speaking && <span className="ml-2 text-neutral-700">🔊 speaking…</span>}
|
|
{voice.error && (
|
|
<span className="ml-2 text-amber-600">voice input error: {voice.error}</span>
|
|
)}
|
|
{speech.error && (
|
|
<span className="ml-2 text-amber-600">speech error: {speech.error}</span>
|
|
)}
|
|
{!voice.supported && (
|
|
<span className="ml-2 text-neutral-400">
|
|
(voice input not supported in this browser)
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|