동의 게이트와 런타임 안정화

This commit is contained in:
Yun Chan 2026-06-27 17:22:38 +09:00
parent 0eb7d925ed
commit 0ec266a761
34 changed files with 1186 additions and 158 deletions

View file

@ -35,6 +35,7 @@ import {
type SessionDetailResponse,
type SessionStage,
} from "../lib/api";
import { useAuth } from "../lib/auth";
import { formatElapsed, formatTimecode, clamp01 } from "../lib/format";
import { SlideToEnd } from "./session/SlideToEnd";
import "./session/session.css";
@ -377,6 +378,7 @@ function expressionForSession({
export default function Session() {
const { sessionId: routeId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
const { user, acceptConsent } = useAuth();
const routeParam = routeId ?? "";
const routeIsSessionId = useMemo(() => looksLikeSessionId(routeParam), [routeParam]);
@ -400,6 +402,8 @@ export default function Session() {
const [liveSessionId, setLiveSessionId] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
const [startError, setStartError] = useState<string | null>(null);
const [consentChecked, setConsentChecked] = useState(false);
const [consentBusy, setConsentBusy] = useState(false);
// ── 회기/대화 상태 ──
const [stage, setStage] = useState<SessionStage>("라포");
@ -656,13 +660,34 @@ export default function Session() {
);
}
});
} catch {
setStartError("세션을 열지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
} catch (err) {
if (err instanceof ApiError && err.detail === "consent_required") {
setStartError("개인정보 및 연습 기록 처리 동의 후 회기를 시작할 수 있습니다.");
} else {
setStartError("세션을 열지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
}
} finally {
setStarting(false);
}
}, [personaCode, personaSummary, pushSignal]);
const handleAcceptConsent = useCallback(async () => {
if (!consentChecked) {
setStartError("동의 항목을 확인해야 회기를 시작할 수 있습니다.");
return;
}
setConsentBusy(true);
setStartError(null);
try {
await acceptConsent();
pushSignal("pos", "동의 확인");
} catch {
setStartError("동의 상태를 저장하지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
} finally {
setConsentBusy(false);
}
}, [acceptConsent, consentChecked, pushSignal]);
const appendServerClientReply = useCallback(
(replyText: string | null, at = elapsed) => {
if (!replyText) {
@ -1256,10 +1281,13 @@ export default function Session() {
sending;
const elapsedLabel = formatElapsed(elapsed);
const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false;
const canStartSession = personaLoadState === "ready" && personaIsUsable;
const consentRequired = user?.role === "learner" && user.consentAt == null;
const canStartSession = personaLoadState === "ready" && personaIsUsable && !consentRequired;
const prestartTitle = canStartSession
? `${clientName}님과의 회기를 시작할까요?`
: personaLoadState === "ready" && personaSummary
: consentRequired
? "동의 확인 후 회기를 시작할 수 있습니다."
: personaLoadState === "ready" && personaSummary
? "이 내담자는 현재 연습에 사용할 수 없습니다."
: "연습 대상 정보를 확인하고 있습니다.";
const personaStatusMessage =
@ -1371,6 +1399,29 @@ export default function Session() {
<p className="sx-prestart__note">{personaStatusMessage}</p>
) : null}
{startError ? <p className="sx-prestart__err">{startError}</p> : null}
{consentRequired ? (
<div className="sx-consent">
<label>
<input
type="checkbox"
checked={consentChecked}
onChange={(event) => setConsentChecked(event.currentTarget.checked)}
/>
<span>
, AI ,
.
</span>
</label>
<Button
size="sm"
onClick={handleAcceptConsent}
disabled={!consentChecked || consentBusy}
leading={<Icon name="check" size={15} />}
>
{consentBusy ? "저장 중" : "동의 저장"}
</Button>
</div>
) : null}
<div className="sx-prestart__actions">
<Button
size="lg"