v2 #10
@@ -334,16 +334,13 @@ export function TextShimmerLoader({
|
|||||||
lg: "text-base",
|
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 (
|
return (
|
||||||
<div
|
<div className={cn("shimmer font-medium", textSizes[size], className)}>
|
||||||
className={cn(
|
|
||||||
"bg-[linear-gradient(to_right,var(--muted-foreground)_40%,var(--foreground)_60%,var(--muted-foreground)_80%)]",
|
|
||||||
"bg-size-[200%_auto] bg-clip-text font-medium text-transparent",
|
|
||||||
"animate-[shimmer_4s_infinite_linear]",
|
|
||||||
textSizes[size],
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,13 +129,30 @@ 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. */}
|
||||||
|
<div className="relative -m-1 overflow-hidden">
|
||||||
|
{anket.sorular.map((s, i) => {
|
||||||
|
const buAktif = s.ad === aktif;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
key={s.ad}
|
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}
|
name={s.ad}
|
||||||
multiple={s.coklu === true}
|
multiple={s.coklu === true}
|
||||||
required={s.zorunlu !== false}
|
required={s.zorunlu !== false}
|
||||||
disabled={durum === "gonderiliyor"}
|
disabled={durum === "gonderiliyor"}
|
||||||
|
hidden={false}
|
||||||
|
className="p-1"
|
||||||
>
|
>
|
||||||
<QuestionnaireTitle>{s.soru}</QuestionnaireTitle>
|
<QuestionnaireTitle>{s.soru}</QuestionnaireTitle>
|
||||||
{s.aciklama ? <QuestionnaireDescription>{s.aciklama}</QuestionnaireDescription> : null}
|
{s.aciklama ? <QuestionnaireDescription>{s.aciklama}</QuestionnaireDescription> : null}
|
||||||
@@ -130,7 +173,10 @@ export function SohbetAnketi({
|
|||||||
{s.coklu ? "En az bir seçenek işaretle." : "Bir seçenek seç."}
|
{s.coklu ? "En az bir seçenek işaretle." : "Bir seçenek seç."}
|
||||||
</QuestionnaireError>
|
</QuestionnaireError>
|
||||||
</QuestionnaireItem>
|
</QuestionnaireItem>
|
||||||
))}
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<QuestionnaireActions>
|
<QuestionnaireActions>
|
||||||
<QuestionnairePrevious size="sm" />
|
<QuestionnairePrevious size="sm" />
|
||||||
<QuestionnaireSkip size="sm" />
|
<QuestionnaireSkip size="sm" />
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { DotsLoader } from "@/components/ui/loader";
|
import { DotsLoader, TextShimmerLoader } from "@/components/ui/loader";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { olay } from "@/lib/analitik";
|
import { olay } from "@/lib/analitik";
|
||||||
import type { ProfilAlani } from "@/lib/danisman-profil";
|
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
|
/** Danışman mesajlarının avatarı — KolayTercih markası. `canli` iken (cevap
|
||||||
* beklenirken/yazılırken) panel başlığındaki DanışmanlaKonuş işareti gibi
|
* 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
|
* 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
|
* (danışman veriye bakıyor, "…bakıyorum" hapı dönüyor) kareler site
|
||||||
* birbirinin yerine yürür; araç bitince nabza döner. Her geçişte ve cevap
|
* başlığındaki logoyla aynı "rota" koreografisini oynar (her kare sıradakinin
|
||||||
* bitince kareler yumuşakça yerine oturur.
|
* 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 —
|
* `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). */
|
* yalnız "Danışman uyarısı" balonunda (durağan; hareket "düşünüyor" demek). */
|
||||||
export function DanismanAvatar({
|
export function DanismanAvatar({
|
||||||
@@ -304,7 +305,7 @@ export function DanismanAvatar({
|
|||||||
>
|
>
|
||||||
<AnimasyonluMark
|
<AnimasyonluMark
|
||||||
oynat={canli}
|
oynat={canli}
|
||||||
varyant={canli && aracta ? "zincir" : "nabiz"}
|
varyant={canli && aracta ? "rota" : "nabiz"}
|
||||||
renk={renk}
|
renk={renk}
|
||||||
className="size-5"
|
className="size-5"
|
||||||
/>
|
/>
|
||||||
@@ -989,6 +990,16 @@ export function SohbetClient({
|
|||||||
const piller = aracPilleri(m);
|
const piller = aracPilleri(m);
|
||||||
// Soru formu balonun altında (sohbet-anketi.tsx)
|
// Soru formu balonun altında (sohbet-anketi.tsx)
|
||||||
const anket = mesajinAnketi(m);
|
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 (
|
return (
|
||||||
<Message
|
<Message
|
||||||
key={cevapAnahtari(gorunenMesajlar, i)}
|
key={cevapAnahtari(gorunenMesajlar, i)}
|
||||||
@@ -1012,9 +1023,18 @@ export function SohbetClient({
|
|||||||
>
|
>
|
||||||
{baglantilariIsaretle(metin)}
|
{baglantilariIsaretle(metin)}
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
) : (
|
) : soruHazirlaniyor ? null : (
|
||||||
<CevapIskeleti />
|
<CevapIskeleti />
|
||||||
)}
|
)}
|
||||||
|
{soruHazirlaniyor ? (
|
||||||
|
<div role="status" aria-live="polite">
|
||||||
|
<TextShimmerLoader
|
||||||
|
text="Soruları hazırlıyorum…"
|
||||||
|
size="sm"
|
||||||
|
className="px-0.5 text-slate-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{anket ? (
|
{anket ? (
|
||||||
<SohbetAnketi
|
<SohbetAnketi
|
||||||
anket={anket}
|
anket={anket}
|
||||||
|
|||||||
@@ -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
65
src/lib/adim-gecisi.ts
Normal 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user