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.
132 lines
4.1 KiB
TypeScript
132 lines
4.1 KiB
TypeScript
// components/AgentVoiceReply.tsx
|
|
// The "Ysterbaard speaks back to me" loop.
|
|
//
|
|
// Watches the upstream InputMode (driven by VoiceField's onInputModeChange
|
|
// callback). When the user submits in voice mode and speak-back is enabled,
|
|
// this component auto-narrates the reply through window.speechSynthesis.
|
|
// When the user submits in text mode, it stays silent.
|
|
//
|
|
// The reply text is provided by the parent (the actual agent response).
|
|
// This component is purely the orchestration layer — it doesn't generate
|
|
// replies itself. Plug it in front of any text-producing assistant.
|
|
|
|
"use client";
|
|
|
|
import { useCallback, useEffect, useRef } from "react";
|
|
import { useSpeech } from "@/lib/voice/useSpeech";
|
|
import type { InputMode } from "@/components/VoiceField";
|
|
|
|
interface Props {
|
|
/** What the agent wants to say. Empty string = nothing to speak. */
|
|
replyText: string;
|
|
/** Most recent input mode from the upstream VoiceField. */
|
|
inputMode: InputMode;
|
|
/** BCP-47 lang. Defaults to en-ZA. */
|
|
lang?: string;
|
|
/**
|
|
* Whether speak-back is currently enabled by the user. Persisted in
|
|
* localStorage by VoiceField, but the parent can override.
|
|
*/
|
|
speakBackEnabled?: boolean;
|
|
/**
|
|
* Bumped each time the parent wants the reply re-spoken (e.g. a new
|
|
* reply arrived). Increments force the speak() effect to re-fire
|
|
* even if the reply text is identical to the previous one.
|
|
*/
|
|
replyVersion?: number;
|
|
}
|
|
|
|
const SPEAK_BACK_STORAGE_KEY = "wf:speakBackEnabled";
|
|
|
|
/**
|
|
* Side-effect component that auto-speaks the agent reply when:
|
|
* • replyText is non-empty
|
|
* • the most recent user input was voice
|
|
* • speak-back is enabled
|
|
* • a new replyVersion has been emitted
|
|
*
|
|
* Renders nothing visible. Drop it as a sibling of VoiceField.
|
|
*
|
|
* <VoiceField onInputModeChange={setMode} ... />
|
|
* <AgentVoiceReply
|
|
* replyText={agentReply}
|
|
* inputMode={mode}
|
|
* speakBackEnabled={speakBackEnabled}
|
|
* replyVersion={replyVersion}
|
|
* />
|
|
*/
|
|
export default function AgentVoiceReply({
|
|
replyText,
|
|
inputMode,
|
|
lang = "en-ZA",
|
|
speakBackEnabled,
|
|
replyVersion = 0
|
|
}: Props) {
|
|
const speech = useSpeech({ lang });
|
|
|
|
// Read speak-back preference from localStorage if the parent didn't pass one.
|
|
const [persistedEnabled, setPersistedEnabled] = useStatePersistedBoolean(
|
|
SPEAK_BACK_STORAGE_KEY,
|
|
true
|
|
);
|
|
|
|
const effectiveEnabled =
|
|
typeof speakBackEnabled === "boolean" ? speakBackEnabled : persistedEnabled;
|
|
|
|
const lastSpokenVersionRef = useRef<number>(-1);
|
|
|
|
useEffect(() => {
|
|
const trimmed = replyText?.trim();
|
|
if (!trimmed) return;
|
|
if (!effectiveEnabled) return;
|
|
if (inputMode !== "voice") return;
|
|
if (replyVersion === lastSpokenVersionRef.current) return;
|
|
|
|
lastSpokenVersionRef.current = replyVersion;
|
|
// Tiny defer so the caller can finish painting the reply text first.
|
|
const t = setTimeout(() => speech.speak(trimmed), 60);
|
|
return () => clearTimeout(t);
|
|
}, [replyText, replyVersion, inputMode, effectiveEnabled, speech]);
|
|
|
|
// Cancel any in-flight speech on unmount.
|
|
useEffect(() => {
|
|
return () => {
|
|
speech.cancel();
|
|
};
|
|
}, [speech]);
|
|
|
|
// No UI — this is a pure side-effect component.
|
|
return null;
|
|
}
|
|
|
|
/** Tiny localStorage-backed boolean state without a real hook import. */
|
|
function useStatePersistedBoolean(
|
|
key: string,
|
|
fallback: boolean
|
|
): [boolean, (next: boolean) => void] {
|
|
// We import useState lazily so we don't pay for it when the parent overrides.
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
const { useState, useEffect } = require("react") as typeof import("react");
|
|
|
|
const [value, setValue] = useState<boolean>(() => {
|
|
if (typeof window === "undefined") return fallback;
|
|
try {
|
|
const raw = window.localStorage.getItem(key);
|
|
if (raw === null) return fallback;
|
|
return raw === "1";
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
try {
|
|
window.localStorage.setItem(key, value ? "1" : "0");
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, [key, value]);
|
|
|
|
const setter = useCallback((next: boolean) => setValue(next), []);
|
|
return [value, setter];
|
|
}
|