v2 #10

Merged
bilal merged 34 commits from v2 into main 2026-10-11 01:38:50 +03:00
3 changed files with 148 additions and 68 deletions
Showing only changes of commit 3f4095e615 - Show all commits

View File

@@ -9,10 +9,21 @@
// danışman mesajındayken açıktır: cevaplandıysa ya da aday başka bir şey // 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 // yazdıysa kapanır ve tek satırlık durumla kalır (sayfa yenilenince kayıttan
// aynı hâlde döner). // 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 } from "react"; import { useId, useState } from "react";
import { Check, MessageSquareText } from "lucide-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 type { Anket, AnketCevaplari } from "@/lib/ai/sohbet-meta";
import { adimGecisi, adimGecisiVaryantlari } from "@/lib/adim-gecisi";
import { import {
Questionnaire, Questionnaire,
QuestionnaireActions, QuestionnaireActions,
@@ -43,6 +54,12 @@ export function SohbetAnketi({
onGonder: (cevaplar: AnketCevaplari) => void; onGonder: (cevaplar: AnketCevaplari) => void;
}) { }) {
const kimlik = useId(); 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") { if (durum === "cevaplandi" || durum === "kapandi") {
return ( return (
@@ -63,6 +80,11 @@ export function SohbetAnketi({
choices: s.secenekler.map((o) => ({ value: o.deger })), 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>) { function gonder(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
if (durum === "gonderiliyor") return; if (durum === "gonderiliyor") return;
@@ -86,7 +108,11 @@ export function SohbetAnketi({
aria-labelledby={anket.baslik ? `${kimlik}-baslik` : undefined} aria-labelledby={anket.baslik ? `${kimlik}-baslik` : undefined}
aria-label={anket.baslik ? undefined : "Danışmanın sorusu"} aria-label={anket.baslik ? undefined : "Danışmanın sorusu"}
className="rounded-2xl border border-slate-200 bg-card p-4" className="rounded-2xl border border-slate-200 bg-card p-4"
defaultItem={anket.sorular[0]?.ad} item={aktif}
onItemChange={(ad) => {
setGecisBasladi(true);
setAktif(ad);
}}
items={items} items={items}
shortcuts="letters" shortcuts="letters"
onSubmit={gonder} onSubmit={gonder}
@@ -103,34 +129,54 @@ export function SohbetAnketi({
)} )}
/> />
</div> </div>
{anket.sorular.map((s) => ( {/* Kayan sorular taşmasın diye kırpılır; 4 px'lik negatif kenar +
<QuestionnaireItem fieldset'teki p-1 odak halkasına (ring-3) yer bırakır. */}
key={s.ad} <div className="relative -m-1 overflow-hidden">
name={s.ad} {anket.sorular.map((s, i) => {
multiple={s.coklu === true} const buAktif = s.ad === aktif;
required={s.zorunlu !== false} return (
disabled={durum === "gonderiliyor"} <motion.div
> key={s.ad}
<QuestionnaireTitle>{s.soru}</QuestionnaireTitle> // Aktiften önceki sorular sola, sonrakiler sağa park eder;
{s.aciklama ? <QuestionnaireDescription>{s.aciklama}</QuestionnaireDescription> : null} // böylece ileri geçişte yeni soru sağdan, geri dönüşte soldan gelir.
<QuestionnaireChoices> custom={i < aktifIndeks ? 1 : -1}
{s.secenekler.map((o) => ( variants={varyantlar}
<QuestionnaireChoice key={o.deger} value={o.deger}> initial={false}
<span>{o.etiket}</span> animate={buAktif ? "center" : "exit"}
{o.aciklama ? ( transition={gecis}
<QuestionnaireChoiceDescription>{o.aciklama}</QuestionnaireChoiceDescription> >
) : null} <QuestionnaireItem
</QuestionnaireChoice> ref={buAktif ? olcumRef : undefined}
))} name={s.ad}
{s.serbest ? ( multiple={s.coklu === true}
<QuestionnaireInput aria-label="Kendi cevabın" placeholder={s.serbest} maxLength={200} /> required={s.zorunlu !== false}
) : null} disabled={durum === "gonderiliyor"}
</QuestionnaireChoices> hidden={false}
<QuestionnaireError> className="p-1"
{s.coklu ? "En az bir seçenek işaretle." : "Bir seçenek seç."} >
</QuestionnaireError> <QuestionnaireTitle>{s.soru}</QuestionnaireTitle>
</QuestionnaireItem> {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> <QuestionnaireActions>
<QuestionnairePrevious size="sm" /> <QuestionnairePrevious size="sm" />
<QuestionnaireSkip size="sm" /> <QuestionnaireSkip size="sm" />

View File

@@ -14,6 +14,7 @@ import useMeasure from "react-use-measure";
import { toast } from "@/components/ui/bildirim"; import { toast } from "@/components/ui/bildirim";
import { cities } from "turkey-map-react/lib/data"; import { cities } from "turkey-map-react/lib/data";
import { TransitionPanel } from "@/components/core/transition-panel"; import { TransitionPanel } from "@/components/core/transition-panel";
import { adimGecisi, adimGecisiVaryantlari } from "@/lib/adim-gecisi";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { IlSecimHaritasi } from "./il-secim-haritasi"; import { IlSecimHaritasi } from "./il-secim-haritasi";
import { normalizeIlAdi } from "@/lib/harita"; import { normalizeIlAdi } from "@/lib/harita";
@@ -249,47 +250,15 @@ export function SihirbazAdimlar({
</p> </p>
</div> </div>
{/* Geçiş sabitleri soru formuyla ortak (lib/adim-gecisi.ts) */}
<TransitionPanel <TransitionPanel
activeIndex={adim} activeIndex={adim}
custom={yon} custom={yon}
variants={{ variants={adimGecisiVaryantlari({
enter: (gecisYonu: number) => ({ hareketiAzalt: !!hareketiAzalt,
x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%", yukseklik: panelYuksekligi,
opacity: 0, })}
height: panelYuksekligi, transition={adimGecisi(!!hareketiAzalt)}
position: "relative",
}),
center: {
zIndex: 1,
x: 0,
opacity: 1,
height: panelYuksekligi,
},
exit: (gecisYonu: number) => ({
zIndex: 0,
x: hareketiAzalt ? 0 : gecisYonu < 0 ? "100%" : "-100%",
opacity: 0,
position: "absolute",
top: 0,
left: 0,
width: "100%",
}),
}}
transition={
hareketiAzalt
? // Azaltılmış harekette yalnızca kısa çapraz solma; height/x
// varsayılan spring'e düşüp oynamasın diye açıkça sıfırlanır.
{
opacity: { duration: 0.12 },
x: { duration: 0 },
height: { duration: 0 },
}
: {
x: { type: "spring", stiffness: 300, damping: 30 },
height: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}
}
> >
<div ref={panelRef} className="px-px pb-2"> <div ref={panelRef} className="px-px pb-2">
<h3 className="font-heading text-xl font-bold text-slate-900"> <h3 className="font-heading text-xl font-bold text-slate-900">

65
src/lib/adim-gecisi.ts Normal file
View File

@@ -0,0 +1,65 @@
// Adım adım ilerleyen panellerin ortak geçişi (DESIGN.md §6): sihirbazın
// alan → il → öncelik adımları (sihirbaz-adimlar.tsx, TransitionPanel) ile
// danışmanın soru formundaki sorular (sohbet-anketi.tsx) aynı yoldan gelir
// gider — yeni adım ileri giderken sağdan, geri dönerken soldan kayar; boy
// ölçülen yüksekliğe yayla uzar. Sabitler yalnız burada; iki yerde de bu iki
// fonksiyon çağrılır, kopyası yazılmaz. Yay sekmesiz (stiffness 300 /
// damping 30), opaklık 0,2 s. "Hareketi azalt"ta kayma ve boy yayı yok,
// yalnız 0,12 s'lik çapraz solma.
import type { Transition, Variants } from "motion/react";
/**
* enter / center / exit varyantları; `custom` geçiş yönüdür (1 ileri, -1
* geri). Giren panel akışta (`relative`) durur, çıkan panel mutlak konuma
* alınır ki ikisi aynı anda üst üste kayabilsin. `yukseklik` ölçülen boy
* (ilk ölçümden önce "auto").
*/
export function adimGecisiVaryantlari({
hareketiAzalt,
yukseklik,
}: {
hareketiAzalt: boolean;
yukseklik: number | "auto";
}): Variants {
return {
enter: (gecisYonu: number) => ({
x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%",
opacity: 0,
height: yukseklik,
position: "relative",
}),
center: {
zIndex: 1,
x: 0,
opacity: 1,
height: yukseklik,
position: "relative",
},
exit: (gecisYonu: number) => ({
zIndex: 0,
x: hareketiAzalt ? 0 : gecisYonu < 0 ? "100%" : "-100%",
opacity: 0,
position: "absolute",
top: 0,
left: 0,
width: "100%",
}),
};
}
/** Geçişin zamanlaması; azaltılmış harekette x/height açıkça sıfırlanır ki
* varsayılan yaya düşüp oynamasın. */
export function adimGecisi(hareketiAzalt: boolean): Transition {
return hareketiAzalt
? {
opacity: { duration: 0.12 },
x: { duration: 0 },
height: { duration: 0 },
}
: {
x: { type: "spring", stiffness: 300, damping: 30 },
height: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
};
}