(a) `sihirbaz_adim{adim:1}` artık sihirbaz modalı açıldığında atılıyor.
Bugüne kadar olay yalnız `adimaGit`in ileri geçişinde atılıyordu; modal
zaten 1. adımda açıldığı için 1. adım hiç "geçilmiyor", dolayısıyla hiç
sayılmıyordu ve huninin paydası `sira_girildi`den türetilmek zorunda
kalıyordu. Mount effect'i + `olayBirKez` ile tam bir kez düşüyor:
1→2→1→2 gezinmesi bileşeni unmount etmediği için tekrar tetiklemiyor,
sessionStorage tekilleştirmesi de aynı sekmede ikinci açılışı sayıyor.
SERİ KIRILMASI: bu değişikliğin canlıya çıktığı tarihten ÖNCEKİ
`sihirbaz_adim` serisinin paydası farklıdır — o dönemde `adim:1` hiç
atılmadı, seri yalnız 2 ve 3'ten oluşuyor. Öncesi ve sonrası
karşılaştırılamaz; huni okumalarında kırılma tarihi dipnot edilmeli.
Aynı desen `program_eklendi` için 21 Eylül'de uygulanmıştı.
(b) Kaynaksız 10 `/paket` linkine tekil `?kaynak=` eklendi; sitedeki 14
`/paket` linkinin 14'ünde artık kaynak var. Değerler mevcut şemayla
(`kilit`, `kredi_bitti`, `nav`) aynı dilde: fiyat_paket, fiyat_kredi,
kosullar, tercih_degisti, tercih_degisti_yetersiz, danisman_rozet,
danisman_kredi_bitti, danisman_kredi_az, odeme_geri, odeme_hata.
`paket-gorunum-olayi.tsx`'teki `KAYNAKLAR` allowlist'i de genişletildi:
listede olmayan her değer `"diger"`e düştüğü için, genişletilmeseydi 10
yeni kaynağın tamamı tek kovada toplanır ve ölçüm deliği kapanmazdı
(bağımlılık kapanışı — dosya şartnamenin listesinde yoktu, raporda
sapma olarak yazıldı).
Görsel değişiklik yok; yeni olay adı ya da yeni ölçüm yüzeyi eklenmedi.
Doğrulama: `next typegen` + `tsc --noEmit` + `lint` temiz.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
585 lines
22 KiB
TypeScript
585 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import { startTransition, useEffect, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import Image from "next/image";
|
||
import { ArrowUp, Coins, Info, Lock, Sparkles, Trash2 } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { cn } from "@/lib/utils";
|
||
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
ChatContainerContent,
|
||
ChatContainerRoot,
|
||
ChatContainerScrollAnchor,
|
||
} from "@/components/ui/chat-container";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Message, MessageContent } from "@/components/ui/message";
|
||
import { preloadMarkdown } from "@/components/ui/markdown-lazy";
|
||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||
import {
|
||
PromptInput,
|
||
PromptInputAction,
|
||
PromptInputActions,
|
||
PromptInputTextarea,
|
||
} from "@/components/ui/prompt-input";
|
||
import { PromptSuggestion } from "@/components/ui/prompt-suggestion";
|
||
import { ScrollButton } from "@/components/ui/scroll-button";
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipProvider,
|
||
TooltipTrigger,
|
||
} from "@/components/ui/tooltip";
|
||
import { DotsLoader } from "@/components/ui/loader";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { olay } from "@/lib/analitik";
|
||
|
||
export interface Mesaj {
|
||
id: string;
|
||
role: "user" | "assistant";
|
||
content: string;
|
||
}
|
||
|
||
export interface DanismanOzeti {
|
||
metin: string;
|
||
kilitli?: boolean;
|
||
}
|
||
|
||
/** Liste kurulunca sohbetin açılışında gösterilen 1. tercih yorumu.
|
||
* Metin ACIK_SATIR maskesinin her zaman açık bıraktığı ilk satırdan gelir,
|
||
* yani paketsiz kullanıcıda da gerçek Yapay Zeka yorumudur. */
|
||
export interface IlkTercihYorumu {
|
||
program: string;
|
||
universite: string;
|
||
metin: string;
|
||
}
|
||
|
||
/** Danışman panellerinin ortak başlığı: animasyonlu logo + site header'daki
|
||
* KolayTercih wordmark deseninde "DanışmanlaKonuş" (ilk kelime ince, ikinci
|
||
* kalın, boşluksuz). İşaret ortak AnimasyonluMark bileşeni — header
|
||
* logosuyla aynı nabız animasyonu, burada sürekli atar. */
|
||
export function DanismanBaslik() {
|
||
return (
|
||
// gap yok denecek kadar az (0.5): SVG viewBox'ının kendi iç boşluğu
|
||
// zaten nefes payı bırakıyor.
|
||
<h2 className="flex min-w-0 flex-1 items-center gap-0.5 font-bricolage text-base tracking-tighter">
|
||
<AnimasyonluMark varyant="nabiz" renk="turuncu" className="size-5" />
|
||
<span className="truncate">
|
||
<span className="font-light">Danışmanla</span>
|
||
<span className="font-semibold">Konuş</span>
|
||
</span>
|
||
</h2>
|
||
);
|
||
}
|
||
|
||
/** Danışman mesajlarının avatarı — KolayTercih markası. `canli` iken (cevap
|
||
* beklenirken/yazılırken) statik görsel yerine başlıktaki nabız animasyonu
|
||
* oynar: danışmanın "düşündüğü" avatardan okunur. */
|
||
export function DanismanAvatar({ canli = false }: { canli?: boolean }) {
|
||
return (
|
||
<span
|
||
className="mt-0.5 flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-slate-200 bg-white"
|
||
aria-hidden
|
||
>
|
||
{canli ? (
|
||
<AnimasyonluMark varyant="nabiz" className="size-5" />
|
||
) : (
|
||
<Image
|
||
src="/kolay-tercih-mark.svg"
|
||
alt=""
|
||
width={32}
|
||
height={32}
|
||
className="size-5"
|
||
/>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
const ORNEK_SORULAR = [
|
||
"Listemdeki kırmızı (riskli) tercihler ne kadar riskli?",
|
||
"İlk 5 tercihimin taban sıralaması trendi nasıl?",
|
||
"Güvenli dilimden bir tercihi daha üste alsam mantıklı mı?",
|
||
"X bölümü ile Y bölümü arasında kalırsam neye bakmalıyım?",
|
||
];
|
||
|
||
/**
|
||
* Listem sayfasındaki danışman sohbeti. /api/soru ile konuşur:
|
||
* GET geçmiş + kredi, POST 1 kredi düşerek cevabı stream eder.
|
||
*/
|
||
export function SohbetClient({
|
||
baslangicKredi,
|
||
initialMesajlar,
|
||
className,
|
||
ozet,
|
||
ilkYorum,
|
||
bildikleri,
|
||
}: {
|
||
baslangicKredi: number;
|
||
/** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */
|
||
initialMesajlar?: Mesaj[];
|
||
/** Boyutlandırma override'ı (ör. alt popup'ta h-full) */
|
||
className?: string;
|
||
/** Raporun genel değerlendirmesi; sohbetin açılış mesajı olarak gösterilir */
|
||
ozet?: DanismanOzeti;
|
||
/** 1. tercihin Yapay Zeka yorumu; sohbet boşken (taze kurulmuş liste)
|
||
* özetin altında açılış balonu olarak durur — üretim panelindeki "ilk
|
||
* tercihinin yorumunu burada okuyacaksın" sözünün karşılığı. */
|
||
ilkYorum?: IlkTercihYorumu;
|
||
/** Danışmanın prompt'unda gerçekten yer alan bağlam maddeleri; başlıktaki
|
||
* "Danışmanın bildikleri" ipucunda listelenir. Boşsa ipucu çizilmez. */
|
||
bildikleri?: string[];
|
||
}) {
|
||
const [mesajlar, setMesajlar] = useState<Mesaj[]>(initialMesajlar ?? []);
|
||
const [girdi, setGirdi] = useState("");
|
||
const [bekliyor, setBekliyor] = useState(false);
|
||
const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
|
||
const [kredi, setKredi] = useState(baslangicKredi);
|
||
const [temizleAcik, setTemizleAcik] = useState(false);
|
||
const [temizleniyor, setTemizleniyor] = useState(false);
|
||
const router = useRouter();
|
||
|
||
// Markdown chunk'ını sohbet görünür olur olmaz ısıt: ilk danışman cevabı
|
||
// (veya geçmiş mesajlar) çizilirken lazy sınırında düz-metin flash'ı olmasın.
|
||
useEffect(() => {
|
||
void preloadMarkdown();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma;
|
||
// popup gibi geç mount olan yerlerde eski davranış sürer.
|
||
if (initialMesajlar != null) return;
|
||
// ttlMs: 0 = yalnız in-flight dedup — /sonuc popup'ı ile /listem aynı anda
|
||
// mount olursa tek istek; kredi/geçmiş POST sonrası değiştiği için
|
||
// TTL cache'i bilinçli kapalı.
|
||
onbellekliJsonGetir<{ mesajlar?: Mesaj[]; kredi?: number }>("/api/soru", {
|
||
ttlMs: 0,
|
||
})
|
||
.then((data) => {
|
||
if (data?.mesajlar) setMesajlar(data.mesajlar);
|
||
if (typeof data?.kredi === "number") setKredi(data.kredi);
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => setYuklendi(true));
|
||
}, [initialMesajlar]);
|
||
|
||
async function gonder(metin: string) {
|
||
const mesaj = metin.trim();
|
||
if (!mesaj || bekliyor) return;
|
||
if (kredi < 1) {
|
||
toast.error("Kredin bitti — devam etmek için kredi yükle.");
|
||
return;
|
||
}
|
||
setGirdi("");
|
||
setBekliyor(true);
|
||
const clientMessageId = crypto.randomUUID();
|
||
const asistanId = crypto.randomUUID();
|
||
setMesajlar((m) => [
|
||
...m,
|
||
{ id: clientMessageId, role: "user", content: mesaj },
|
||
{ id: asistanId, role: "assistant", content: "" },
|
||
]);
|
||
|
||
let birikmis = "";
|
||
try {
|
||
const res = await fetch("/api/soru", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ message: mesaj, clientMessageId }),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
setMesajlar((m) =>
|
||
m.filter((x) => x.id !== asistanId && x.id !== clientMessageId),
|
||
);
|
||
// Mesaj işlenmedi; kullanıcının yazdığı soru kaybolmasın
|
||
setGirdi(mesaj);
|
||
if (res.status === 402) {
|
||
toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin.");
|
||
setKredi(0);
|
||
} else {
|
||
toast.error(data.error ?? "Bir sorun oluştu, tekrar dener misin?");
|
||
}
|
||
return;
|
||
}
|
||
|
||
setKredi((k) => k - 1);
|
||
// Bu bileşen /sonuc'taki popup'tan da kullanılıyor (sohbet-popup.tsx)
|
||
olay("danisman_sorusu", {
|
||
sayfa: location.pathname.startsWith("/listem") ? "listem" : "sonuc",
|
||
});
|
||
const reader = res.body!.getReader();
|
||
const decoder = new TextDecoder();
|
||
for (;;) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
birikmis += decoder.decode(value, { stream: true });
|
||
const anlik = birikmis;
|
||
// Chunk başına gelen güncelleme acil değil: transition'a alınca
|
||
// kullanıcı stream sürerken input'a takılmadan yazabilir
|
||
startTransition(() =>
|
||
setMesajlar((m) =>
|
||
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
|
||
),
|
||
);
|
||
}
|
||
// Header'daki kredi pill'i layout'ta server-render edilir ve client
|
||
// harcamasını görmez; bakiye orada da güncellensin.
|
||
router.refresh();
|
||
} catch {
|
||
// Sunucu hata anında krediyi iade edip yarım cevabı kaydetmiş olabilir;
|
||
// tahmin etme: kayıtlı geçmişi ve bakiyeyi sunucudan çek (boş balon da
|
||
// bu senkronla temizlenir). Senkron da düşerse boş balonu elle kaldır.
|
||
try {
|
||
const res = await fetch("/api/soru");
|
||
const data = res.ok ? await res.json() : null;
|
||
if (data?.mesajlar) setMesajlar(data.mesajlar);
|
||
if (typeof data?.kredi === "number") setKredi(data.kredi);
|
||
} catch {}
|
||
if (!birikmis) {
|
||
setMesajlar((m) => m.filter((x) => x.id !== asistanId));
|
||
// Cevap hiç gelmedi; kullanıcının yazdığı soru kaybolmasın
|
||
setGirdi(mesaj);
|
||
}
|
||
router.refresh();
|
||
toast.error(
|
||
birikmis
|
||
? "Bağlantı koptu — cevabın buraya kadar üretilen kısmı kaydedildi."
|
||
: "Bağlantı koptu, cevap alınamadı. Kredin harcandıysa otomatik iade edildi.",
|
||
);
|
||
} finally {
|
||
setBekliyor(false);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sohbet geçmişini hem sunucudan hem ekrandan siler. Krediler iade
|
||
* edilmez; danışman bir sonraki soruda sıfır geçmişle başlar (rapor özeti
|
||
* ve liste bağlamı prompt'tan geldiği için korunur).
|
||
*/
|
||
async function sohbetiTemizle() {
|
||
if (temizleniyor) return;
|
||
setTemizleniyor(true);
|
||
try {
|
||
const res = await fetch("/api/soru", { method: "DELETE" });
|
||
if (!res.ok) throw new Error("silinemedi");
|
||
setMesajlar([]);
|
||
setTemizleAcik(false);
|
||
// Sunucudan gelen geçmiş (listem sayfasının initialMesajlar'ı) da boşalsın
|
||
router.refresh();
|
||
toast.success("Sohbet temizlendi.");
|
||
} catch {
|
||
toast.error("Sohbet temizlenemedi, tekrar dener misin?");
|
||
} finally {
|
||
setTemizleniyor(false);
|
||
}
|
||
}
|
||
|
||
const krediYok = kredi < 1;
|
||
|
||
return (
|
||
<section
|
||
aria-label="Danışman sohbeti"
|
||
className={cn(
|
||
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white",
|
||
// 18rem: sticky ofset (top-28 = 7rem) + geniş alt boşluk — eski 24rem
|
||
// sohbeti sıkıştırıyordu, 10-14rem denemeleri fazla yüksek geldi;
|
||
// panel görünür ama sayfaya baskın değil.
|
||
className ?? "h-[calc(100dvh-18rem)] min-h-[380px]",
|
||
)}
|
||
>
|
||
{/* Başlık solda, aksiyonlar sağda tek grup: dar panelde de sığar.
|
||
Temizle butonu ikon-only — metinli hâli başlıkla yarışıp taşıyordu. */}
|
||
<div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3">
|
||
<DanismanBaslik />
|
||
<div className="flex shrink-0 items-center gap-1.5">
|
||
{bildikleri?.length ? (
|
||
<TooltipProvider delayDuration={120}>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
aria-label="Danışmanın bildikleri"
|
||
className="inline-flex size-7 cursor-help items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-800"
|
||
>
|
||
<Info className="size-3.5" aria-hidden />
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent
|
||
side="bottom"
|
||
sideOffset={6}
|
||
className="max-w-xs flex-col items-start gap-1.5 p-3 text-left leading-relaxed"
|
||
>
|
||
<p className="font-semibold">
|
||
Danışmanın bildikleri — cevaplar bu bağlamla üretilir:
|
||
</p>
|
||
<ul className="flex list-disc flex-col gap-1 pl-4">
|
||
{bildikleri.map((b) => (
|
||
<li key={b}>{b}</li>
|
||
))}
|
||
</ul>
|
||
<p className="text-background/70">
|
||
Adın, e-postan ve netlerin danışmanla paylaşılmaz.
|
||
</p>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
) : null}
|
||
{mesajlar.length > 0 ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setTemizleAcik(true)}
|
||
disabled={bekliyor || temizleniyor}
|
||
aria-label="Sohbeti temizle"
|
||
className="inline-flex size-7 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96] disabled:cursor-not-allowed disabled:opacity-50"
|
||
title="Sohbet geçmişini sil"
|
||
>
|
||
<Trash2 className="size-3.5" aria-hidden />
|
||
</button>
|
||
) : null}
|
||
<Link
|
||
href="/paket?kaynak=danisman_rozet"
|
||
className="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700 transition-colors hover:bg-amber-100"
|
||
title="Kredilerin — yüklemek için tıkla"
|
||
>
|
||
<Coins className="size-3.5" aria-hidden />
|
||
{kredi} kredi
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
<Dialog open={temizleAcik} onOpenChange={setTemizleAcik}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Sohbeti temizle</DialogTitle>
|
||
<DialogDescription>
|
||
Danışmanla olan tüm yazışman silinir ve geri getirilemez. Listen
|
||
ve kredilerin etkilenmez; yeni bir soruyla sıfırdan devam
|
||
edebilirsin.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
className="cursor-pointer"
|
||
onClick={() => setTemizleAcik(false)}
|
||
disabled={temizleniyor}
|
||
>
|
||
Vazgeç
|
||
</Button>
|
||
<Button
|
||
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||
onClick={sohbetiTemizle}
|
||
disabled={temizleniyor}
|
||
>
|
||
{temizleniyor ? <DotsLoader size="sm" /> : "Sohbeti temizle"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<div className="relative flex-1 overflow-hidden">
|
||
<ChatContainerRoot className="h-full">
|
||
<ChatContainerContent className="gap-4 p-4">
|
||
{ozet ? (
|
||
<Message className="justify-start">
|
||
<DanismanAvatar />
|
||
<div className="flex max-w-[85%] flex-col gap-2">
|
||
<p className="text-xs font-semibold text-slate-500">
|
||
Danışman özeti
|
||
</p>
|
||
<MessageContent className="bg-slate-100 text-slate-800">
|
||
{ozet.metin}
|
||
</MessageContent>
|
||
{ozet.kilitli ? (
|
||
<p className="flex items-center gap-1.5 text-xs text-slate-500">
|
||
<Lock className="size-3.5" aria-hidden />
|
||
Değerlendirmenin tamamı pakette.
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</Message>
|
||
) : null}
|
||
{ilkYorum && mesajlar.length === 0 ? (
|
||
<Message className="justify-start">
|
||
<DanismanAvatar />
|
||
<div className="flex max-w-[85%] flex-col gap-2">
|
||
<p className="text-xs font-semibold text-slate-500">
|
||
1. tercihin: {ilkYorum.program} — {ilkYorum.universite}
|
||
</p>
|
||
<MessageContent className="bg-slate-100 text-slate-800">
|
||
{ilkYorum.metin}
|
||
</MessageContent>
|
||
</div>
|
||
</Message>
|
||
) : null}
|
||
{mesajlar.length === 0 ? (
|
||
<div
|
||
className={cn(
|
||
"flex flex-col gap-4",
|
||
!ozet && "items-center py-10 text-center",
|
||
)}
|
||
>
|
||
{!ozet ? (
|
||
<>
|
||
<Sparkles
|
||
className="mx-auto size-7 text-primary"
|
||
aria-hidden
|
||
/>
|
||
<div>
|
||
<p className="font-heading font-bold">
|
||
Listen hakkında her şeyi sorabilirsin
|
||
</p>
|
||
<p className="mx-auto mt-1 max-w-md text-sm text-slate-600">
|
||
Cevaplar senin 24'lük listen, seçimlerin ve gerçek
|
||
YÖK Atlas verisi bağlamında üretilir. Her mesaj 1 kredi.
|
||
</p>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
{yuklendi ? (
|
||
<div
|
||
className={cn(
|
||
"flex max-w-lg flex-wrap gap-2",
|
||
!ozet && "justify-center",
|
||
)}
|
||
>
|
||
{ORNEK_SORULAR.map((s) => (
|
||
<PromptSuggestion
|
||
key={s}
|
||
size="sm"
|
||
className="cursor-pointer text-xs"
|
||
disabled={krediYok}
|
||
onClick={() => gonder(s)}
|
||
>
|
||
{s}
|
||
</PromptSuggestion>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div
|
||
className={cn(
|
||
"flex max-w-lg flex-wrap gap-2",
|
||
!ozet && "justify-center",
|
||
)}
|
||
>
|
||
{[112, 148, 96, 132].map((w, i) => (
|
||
<Skeleton
|
||
key={i}
|
||
className="h-7 rounded-full"
|
||
style={{ width: w }}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
mesajlar.map((m, i) =>
|
||
m.role === "user" ? (
|
||
<Message key={m.id} className="justify-end">
|
||
<MessageContent className="max-w-[85%] bg-primary text-white">
|
||
{m.content}
|
||
</MessageContent>
|
||
</Message>
|
||
) : (
|
||
<Message key={m.id} className="justify-start">
|
||
{/* Cevap beklenirken/stream sürerken (son mesaj) avatar
|
||
nabız animasyonuna geçer. */}
|
||
<DanismanAvatar
|
||
canli={bekliyor && i === mesajlar.length - 1}
|
||
/>
|
||
{m.content ? (
|
||
<MessageContent
|
||
markdown
|
||
className="max-w-[85%] bg-slate-100 text-slate-800 prose-sm"
|
||
>
|
||
{m.content}
|
||
</MessageContent>
|
||
) : (
|
||
<div className="w-full max-w-[85%] space-y-2 rounded-lg bg-slate-100 px-4 py-3">
|
||
<Skeleton className="h-3.5 w-full bg-slate-200" />
|
||
<Skeleton className="h-3.5 w-4/5 bg-slate-200" />
|
||
<Skeleton className="h-3.5 w-3/5 bg-slate-200" />
|
||
</div>
|
||
)}
|
||
</Message>
|
||
),
|
||
)
|
||
)}
|
||
<ChatContainerScrollAnchor />
|
||
</ChatContainerContent>
|
||
<div className="absolute bottom-4 right-4">
|
||
<ScrollButton className="cursor-pointer bg-white shadow-md" />
|
||
</div>
|
||
</ChatContainerRoot>
|
||
</div>
|
||
|
||
<div className="border-t border-slate-100 p-3">
|
||
{krediYok ? (
|
||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
|
||
<p className="text-sm font-medium text-amber-800">
|
||
Kredin bitti — sohbete devam etmek için kredi yükle.
|
||
</p>
|
||
<Button
|
||
asChild
|
||
size="sm"
|
||
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||
>
|
||
<Link href="/paket?kaynak=danisman_kredi_bitti">Kredi yükle</Link>
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{kredi <= 3 ? (
|
||
<p className="mb-2 px-1 text-xs text-amber-700">
|
||
Son {kredi} kredin — bitse de listen ve sohbetin kaybolmaz;{" "}
|
||
<Link href="/paket?kaynak=danisman_kredi_az" className="underline hover:text-amber-800">
|
||
kredi yükleyip
|
||
</Link>{" "}
|
||
kaldığın yerden devam edersin.
|
||
</p>
|
||
) : null}
|
||
<PromptInput
|
||
value={girdi}
|
||
onValueChange={setGirdi}
|
||
onSubmit={() => gonder(girdi)}
|
||
isLoading={bekliyor}
|
||
disabled={bekliyor}
|
||
maxHeight={160}
|
||
className="rounded-2xl border-slate-200"
|
||
>
|
||
<PromptInputTextarea
|
||
placeholder="Sorunu yaz… (1 kredi)"
|
||
maxLength={2000}
|
||
/>
|
||
<PromptInputActions className="justify-end pt-1">
|
||
<PromptInputAction tooltip="Gönder (Enter)">
|
||
<Button
|
||
size="icon"
|
||
onClick={() => gonder(girdi)}
|
||
disabled={bekliyor || !girdi.trim()}
|
||
className="size-9 cursor-pointer rounded-full bg-orange-500 text-white hover:bg-orange-600"
|
||
aria-label="Gönder"
|
||
>
|
||
{bekliyor ? <DotsLoader size="sm" /> : <ArrowUp className="size-4" aria-hidden />}
|
||
</Button>
|
||
</PromptInputAction>
|
||
</PromptInputActions>
|
||
</PromptInput>
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|