From f08a26a0220876a2fb5a1efe7b3ffce110743b46 Mon Sep 17 00:00:00 2001
From: bilalgursen
Date: Wed, 7 Oct 2026 23:14:34 +0300
Subject: [PATCH 1/2] =?UTF-8?q?feat(sohbet):=20soru=20formu=20haz=C4=B1rla?=
=?UTF-8?q?n=C4=B1rken=20shimmer;=20ara=C3=A7ta=20avatar=20header=20logosu?=
=?UTF-8?q?yla=20ayn=C4=B1=20rota=20koreografisi?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- soru_sor aracı hap çizmediğinden sorular üretilirken gri iskelet yerine
"Soruları hazırlıyorum…" shimmer satırı (role=status, aria-live=polite);
form gelince kalkar.
- DanismanAvatar araçta "zincir" yerine site başlığındaki logoyla aynı "rota".
- TextShimmerLoader (prompt-kit) projede tanımsız `shimmer` keyframe'i ve
muted/foreground token'ı kullanıyordu; sitenin shadcn `shimmer` utility'sine
geçti (liste-uretici / tercih-degisti-modali ile aynı kaynak).
Co-Authored-By: Claude Fable 5.1
---
src/components/ui/loader.tsx | 15 ++++-----
.../rapor/components/sohbet-client.tsx | 32 +++++++++++++++----
2 files changed, 32 insertions(+), 15 deletions(-)
diff --git a/src/components/ui/loader.tsx b/src/components/ui/loader.tsx
index caccb3c..92694c7 100644
--- a/src/components/ui/loader.tsx
+++ b/src/components/ui/loader.tsx
@@ -334,16 +334,13 @@ export function TextShimmerLoader({
lg: "text-base",
}
+ // prompt-kit'in özgün gradyanı `--muted-foreground/--foreground` token'ı ve
+ // projede tanımsız bir `shimmer` keyframe'i kullanıyordu (hareket etmiyordu).
+ // Sitenin tek parıltı kaynağı shadcn `shimmer` utility'sidir (currentColor
+ // üstünden; liste-uretici.tsx ve tercih-degisti-modali.tsx ile aynı), renk
+ // çağıranın `text-slate-*` sınıfından gelir, karanlık mod token'dan döner.
return (
-
+
{text}
)
diff --git a/src/features/rapor/components/sohbet-client.tsx b/src/features/rapor/components/sohbet-client.tsx
index 59a8846..e0be39b 100644
--- a/src/features/rapor/components/sohbet-client.tsx
+++ b/src/features/rapor/components/sohbet-client.tsx
@@ -54,7 +54,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { DotsLoader } from "@/components/ui/loader";
+import { DotsLoader, TextShimmerLoader } from "@/components/ui/loader";
import { Skeleton } from "@/components/ui/skeleton";
import { olay } from "@/lib/analitik";
import type { ProfilAlani } from "@/lib/danisman-profil";
@@ -283,9 +283,10 @@ export function DanismanBaslik() {
/** Danışman mesajlarının avatarı — KolayTercih markası. `canli` iken (cevap
* beklenirken/yazılırken) panel başlığındaki DanışmanlaKonuş işareti gibi
* yerinde nabız atar: danışmanın "düşündüğü" avatardan okunur. `aracta` iken
- * (danışman veriye bakıyor, "…bakıyorum" hapı dönüyor) kareler sırayla
- * birbirinin yerine yürür; araç bitince nabza döner. Her geçişte ve cevap
- * bitince kareler yumuşakça yerine oturur.
+ * (danışman veriye bakıyor, "…bakıyorum" hapı dönüyor) kareler site
+ * başlığındaki logoyla aynı "rota" koreografisini oynar (her kare sıradakinin
+ * yerine süzülür; site-header.tsx, animasyonlu-mark.tsx); araç bitince nabza
+ * döner. Her geçişte ve cevap bitince kareler yumuşakça yerine oturur.
* `renk="turuncu"`: panel başlığındaki DanışmanlaKonuş işaretinin rengi —
* yalnız "Danışman uyarısı" balonunda (durağan; hareket "düşünüyor" demek). */
export function DanismanAvatar({
@@ -304,7 +305,7 @@ export function DanismanAvatar({
>
@@ -989,6 +990,16 @@ export function SohbetClient({
const piller = aracPilleri(m);
// Soru formu balonun altında (sohbet-anketi.tsx)
const anket = mesajinAnketi(m);
+ // soru_sor aracı hap çizmez (arac-pilleri.tsx HAPSIZ_ARACLAR);
+ // sorular üretilirken (parça henüz output-available değil)
+ // formun yerinde shimmer satırı durur, form gelince kalkar.
+ const soruHazirlaniyor = m.parts.some(
+ (p) =>
+ p.type === "tool-soru_sor" &&
+ "state" in p &&
+ p.state !== "output-available" &&
+ p.state !== "output-error",
+ );
return (
{baglantilariIsaretle(metin)}
- ) : (
+ ) : soruHazirlaniyor ? null : (
)}
+ {soruHazirlaniyor ? (
+
+
+
+ ) : null}
{anket ? (
Date: Wed, 7 Oct 2026 23:14:34 +0300
Subject: [PATCH 2/2] =?UTF-8?q?refactor(hareket):=20sihirbaz=20ad=C4=B1m?=
=?UTF-8?q?=20ge=C3=A7i=C5=9Fi=20sabitleri=20lib/adim-gecisi.ts'e;=20soru?=
=?UTF-8?q?=20formu=20ayn=C4=B1=20ge=C3=A7i=C5=9Fle?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
.../rapor/components/sohbet-anketi.tsx | 106 +++++++++++++-----
.../sihirbaz/components/sihirbaz-adimlar.tsx | 45 ++------
src/lib/adim-gecisi.ts | 65 +++++++++++
3 files changed, 148 insertions(+), 68 deletions(-)
create mode 100644 src/lib/adim-gecisi.ts
diff --git a/src/features/rapor/components/sohbet-anketi.tsx b/src/features/rapor/components/sohbet-anketi.tsx
index e972acd..3636519 100644
--- a/src/features/rapor/components/sohbet-anketi.tsx
+++ b/src/features/rapor/components/sohbet-anketi.tsx
@@ -9,10 +9,21 @@
// 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 } from "react";
+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,
@@ -43,6 +54,12 @@ export function SohbetAnketi({
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(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 (
@@ -63,6 +80,11 @@ export function SohbetAnketi({
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) {
e.preventDefault();
if (durum === "gonderiliyor") return;
@@ -86,7 +108,11 @@ export function SohbetAnketi({
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"
- defaultItem={anket.sorular[0]?.ad}
+ item={aktif}
+ onItemChange={(ad) => {
+ setGecisBasladi(true);
+ setAktif(ad);
+ }}
items={items}
shortcuts="letters"
onSubmit={gonder}
@@ -103,34 +129,54 @@ export function SohbetAnketi({
)}
/>
- {anket.sorular.map((s) => (
-
- {s.soru}
- {s.aciklama ? {s.aciklama} : null}
-
- {s.secenekler.map((o) => (
-
- {o.etiket}
- {o.aciklama ? (
- {o.aciklama}
- ) : null}
-
- ))}
- {s.serbest ? (
-
- ) : null}
-
-
- {s.coklu ? "En az bir seçenek işaretle." : "Bir seçenek seç."}
-
-
- ))}
+ {/* 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. */}
+
+ {anket.sorular.map((s, i) => {
+ const buAktif = s.ad === aktif;
+ return (
+
+
+ {s.soru}
+ {s.aciklama ? {s.aciklama} : null}
+
+ {s.secenekler.map((o) => (
+
+ {o.etiket}
+ {o.aciklama ? (
+ {o.aciklama}
+ ) : null}
+
+ ))}
+ {s.serbest ? (
+
+ ) : null}
+
+
+ {s.coklu ? "En az bir seçenek işaretle." : "Bir seçenek seç."}
+
+
+
+ );
+ })}
+
diff --git a/src/features/sihirbaz/components/sihirbaz-adimlar.tsx b/src/features/sihirbaz/components/sihirbaz-adimlar.tsx
index 864c6bd..52caba2 100644
--- a/src/features/sihirbaz/components/sihirbaz-adimlar.tsx
+++ b/src/features/sihirbaz/components/sihirbaz-adimlar.tsx
@@ -14,6 +14,7 @@ import useMeasure from "react-use-measure";
import { toast } from "@/components/ui/bildirim";
import { cities } from "turkey-map-react/lib/data";
import { TransitionPanel } from "@/components/core/transition-panel";
+import { adimGecisi, adimGecisiVaryantlari } from "@/lib/adim-gecisi";
import { Button } from "@/components/ui/button";
import { IlSecimHaritasi } from "./il-secim-haritasi";
import { normalizeIlAdi } from "@/lib/harita";
@@ -249,47 +250,15 @@ export function SihirbazAdimlar({
+ {/* Geçiş sabitleri soru formuyla ortak (lib/adim-gecisi.ts) */}
({
- x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%",
- opacity: 0,
- height: panelYuksekligi,
- 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 },
- }
- }
+ variants={adimGecisiVaryantlari({
+ hareketiAzalt: !!hareketiAzalt,
+ yukseklik: panelYuksekligi,
+ })}
+ transition={adimGecisi(!!hareketiAzalt)}
>
diff --git a/src/lib/adim-gecisi.ts b/src/lib/adim-gecisi.ts
new file mode 100644
index 0000000..7096570
--- /dev/null
+++ b/src/lib/adim-gecisi.ts
@@ -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 },
+ };
+}