Files
kolaytercih/src/features/rapor/components/sohbet-anketi.tsx
bilalgursen 3f4095e615 refactor(hareket): sihirbaz adım geçişi sabitleri lib/adim-gecisi.ts'e; soru formu aynı geçişle
- adimGecisiVaryantlari / adimGecisi tek kaynak (spring 300/30, opaklık 0,2 s;
  hareketi azalt'ta 0,12 s çapraz solma). Sihirbaz davranışı aynı; center
  varyantına `position: relative` eklendi ki geri dönen panel mutlak kalmasın.
- SohbetAnketi kontrollü (item/onItemChange); her soru motion.div içinde,
  pasif sorular hidden={false} + mutlak/saydam park eder (ilkelin inert'i
  kalır), aktif fieldset useMeasure ile ölçülüp boy yayla uzar. Çıkış animasyonu
  da çalışır: mergeProps'ta kullanıcı prop'u kazanıyor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 23:14:34 +03:00

191 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// Danışmanın soru formu (DESIGN.md §4.20, §4.33). `soru_sor` aracı
// (lib/ai/araclar.ts) liste revizyonu isteği belirsizken 1-3 kısa soruyu bu
// formla sorar; sorular tek tek gösterilir (shadcn Questionnaire,
// components/ui/questionnaire.tsx). Gönderilince cevaplar hem adayın balonuna
// okunur metin olarak (lib/ai/sohbet-meta.ts anketCevabiMetni) hem sunucuya
// yapısal olarak gider; bu mesaj kredi harcamaz. Form yalnız sohbetin SON
// danışman mesajındayken açıktır: cevaplandıysa ya da aday başka bir şey
// yazdıysa kapanır ve tek satırlık durumla kalır (sayfa yenilenince kayıttan
// aynı hâlde döner).
//
// Sorular arası geçiş sihirbaz adımlarıyla birebir aynı yoldan (lib/adim-gecisi.ts):
// ileri giderken yeni soru sağdan gelir, eskisi sola çıkar; geri dönerken
// tersi; boy ölçülen yüksekliğe yayla uzar. İlkel pasif soruyu `hidden`
// yapar; burada `hidden={false}` ile görünürlük bize geçer (mergeProps'ta
// kullanıcı prop'u kazanır — @shadcn/react chunk-HBS6WEDP.js `b`), pasif
// sorular mutlak konumda saydam durur ve ilkelin verdiği `inert`i korur;
// DOM'da kaldıkları için gönderimde FormData önceki cevapları da toplar.
import { useId, useState } from "react";
import { Check, MessageSquareText } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure";
import type { Anket, AnketCevaplari } from "@/lib/ai/sohbet-meta";
import { adimGecisi, adimGecisiVaryantlari } from "@/lib/adim-gecisi";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
export type AnketDurumu = "acik" | "gonderiliyor" | "cevaplandi" | "kapandi";
export function SohbetAnketi({
anket,
durum,
onGonder,
}: {
anket: Anket;
durum: AnketDurumu;
onGonder: (cevaplar: AnketCevaplari) => void;
}) {
const kimlik = useId();
// Aktif soru bizde (kontrollü ilkel): geçiş yönü sorunun sırasından çıkar.
const [aktif, setAktif] = useState<string | undefined>(anket.sorular[0]?.ad);
const [gecisBasladi, setGecisBasladi] = useState(false);
// Yalnız aktif sorunun fieldset'i ölçülür; ref soru değişince ona geçer.
const [olcumRef, olculer] = useMeasure();
const hareketiAzalt = !!useReducedMotion();
if (durum === "cevaplandi" || durum === "kapandi") {
return (
<p className="flex items-center gap-1.5 px-0.5 text-xs font-medium text-slate-500">
{durum === "cevaplandi" ? (
<Check className="size-3.5 text-emerald-600" aria-hidden />
) : (
<MessageSquareText className="size-3.5" aria-hidden />
)}
{durum === "cevaplandi" ? "Formu cevapladın." : "Form kapandı; isteğini yazarak söyleyebilirsin."}
</p>
);
}
const items = anket.sorular.map((s) => ({
name: s.ad,
required: s.zorunlu !== false,
choices: s.secenekler.map((o) => ({ value: o.deger })),
}));
const aktifIndeks = anket.sorular.findIndex((s) => s.ad === aktif);
const yukseklik = gecisBasladi && olculer.height > 0 ? olculer.height : "auto";
const varyantlar = adimGecisiVaryantlari({ hareketiAzalt, yukseklik });
const gecis = adimGecisi(hareketiAzalt);
function gonder(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (durum === "gonderiliyor") return;
const veri = new FormData(e.currentTarget);
const cevaplar: AnketCevaplari = {};
for (const s of anket.sorular) {
const hepsi = veri
.getAll(s.ad)
.map((v) => String(v).trim())
.filter(Boolean);
if (hepsi.length === 0) continue;
cevaplar[s.ad] = s.coklu ? [...new Set(hepsi)] : hepsi[hepsi.length - 1];
}
if (Object.keys(cevaplar).length === 0) return;
onGonder(cevaplar);
}
return (
// Sade kutu (§4.4); balonun altında, aynı sütunda durur
<Questionnaire
aria-labelledby={anket.baslik ? `${kimlik}-baslik` : undefined}
aria-label={anket.baslik ? undefined : "Danışmanın sorusu"}
className="rounded-2xl border border-slate-200 bg-card p-4"
item={aktif}
onItemChange={(ad) => {
setGecisBasladi(true);
setAktif(ad);
}}
items={items}
shortcuts="letters"
onSubmit={gonder}
>
<div className="flex items-center justify-between gap-3">
<p id={`${kimlik}-baslik`} className="text-xs font-semibold text-slate-500">
{anket.baslik ?? "Danışmanın sorusu"}
</p>
<QuestionnaireProgress
render={(props, state) => (
<div {...props}>
{state.current} / {state.total}
</div>
)}
/>
</div>
{/* Kayan sorular taşmasın diye kırpılır; 4 px'lik negatif kenar +
fieldset'teki p-1 odak halkasına (ring-3) yer bırakır. */}
<div className="relative -m-1 overflow-hidden">
{anket.sorular.map((s, i) => {
const buAktif = s.ad === aktif;
return (
<motion.div
key={s.ad}
// Aktiften önceki sorular sola, sonrakiler sağa park eder;
// böylece ileri geçişte yeni soru sağdan, geri dönüşte soldan gelir.
custom={i < aktifIndeks ? 1 : -1}
variants={varyantlar}
initial={false}
animate={buAktif ? "center" : "exit"}
transition={gecis}
>
<QuestionnaireItem
ref={buAktif ? olcumRef : undefined}
name={s.ad}
multiple={s.coklu === true}
required={s.zorunlu !== false}
disabled={durum === "gonderiliyor"}
hidden={false}
className="p-1"
>
<QuestionnaireTitle>{s.soru}</QuestionnaireTitle>
{s.aciklama ? <QuestionnaireDescription>{s.aciklama}</QuestionnaireDescription> : null}
<QuestionnaireChoices>
{s.secenekler.map((o) => (
<QuestionnaireChoice key={o.deger} value={o.deger}>
<span>{o.etiket}</span>
{o.aciklama ? (
<QuestionnaireChoiceDescription>{o.aciklama}</QuestionnaireChoiceDescription>
) : null}
</QuestionnaireChoice>
))}
{s.serbest ? (
<QuestionnaireInput aria-label="Kendi cevabın" placeholder={s.serbest} maxLength={200} />
) : null}
</QuestionnaireChoices>
<QuestionnaireError>
{s.coklu ? "En az bir seçenek işaretle." : "Bir seçenek seç."}
</QuestionnaireError>
</QuestionnaireItem>
</motion.div>
);
})}
</div>
<QuestionnaireActions>
<QuestionnairePrevious size="sm" />
<QuestionnaireSkip size="sm" />
<QuestionnaireNext size="sm" />
<QuestionnaireSubmit size="sm" disabled={durum === "gonderiliyor"}>
{durum === "gonderiliyor" ? "Gönderiliyor…" : "Cevapları gönder"}
</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
);
}