Files
kolaytercih/src/features/rapor/components/sohbet-client.tsx
bilalgursen 26893ce7cf feat(olcum): sihirbazın 1. adımı ve kaynaksız /paket linkleri ölçülür
(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>
2026-09-23 00:17:21 +03:00

585 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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&apos;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>
);
}