- 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>
191 lines
7.4 KiB
TypeScript
191 lines
7.4 KiB
TypeScript
"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>
|
||
);
|
||
}
|