- lib/istek-cache: modül seviyesi in-flight dedup + 5dk TTL cache (SWR bağımlılığı olmadan); caller iptali paylaşılan fetch'i düşürmez - /api/facetler'in 5 çağrı yeri (hero, cta, kapı, sihirbaz adımları, demo) ve /api/katalog-ara cache'e bağlandı — sihirbazda 2↔3 adım geçişi ve tekrarlanan aramalar artık ağa çıkmıyor; /api/soru GET dedup-only (ttl 0) - katalog-arama: elle yukleniyor state'i kalktı, "cevaplanmamış sorgu" türetmesi + startTransition; rehber puanlaması Schwartzian; ⌘K listener'ı useEffectEvent ile tek sefer bağlanıyor - sihirbaz-adimlar: il sort/filter useMemo (çip başına 81 eleman kopyalanıp sıralanıyordu, harita dizisi referansı sabitlendi) - sohbet-client: stream chunk güncellemeleri startTransition'da — cevap akarken input yazımı bloklanmıyor - sihirbaz-profil: modül cache + event/storage geçersizlemesi (tek "+" tıklamasında 3 localStorage okuma + JSON.parse vardı) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
434 lines
16 KiB
TypeScript
434 lines
16 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 { AlertTriangle, ArrowUp, Coins, Lock, Sparkles } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { cn } from "@/lib/utils";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
ChatContainerContent,
|
||
ChatContainerRoot,
|
||
ChatContainerScrollAnchor,
|
||
} from "@/components/ui/chat-container";
|
||
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 { 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;
|
||
uyarilar?: string[];
|
||
kilitli?: boolean;
|
||
/** Paketsizde açık uyarının ardından kilitli kalan uyarı sayısı. */
|
||
kilitliUyariSayisi?: number;
|
||
}
|
||
|
||
/** Danışman mesajlarının avatarı — KolayTercih markası. */
|
||
function DanismanAvatar() {
|
||
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
|
||
>
|
||
<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,
|
||
}: {
|
||
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;
|
||
}) {
|
||
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 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);
|
||
}
|
||
}
|
||
|
||
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",
|
||
className ?? "h-[calc(100dvh-16rem)] min-h-[400px]",
|
||
)}
|
||
>
|
||
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||
<h2 className="font-heading text-base font-bold">Danışmanla konuş</h2>
|
||
<Link
|
||
href="/paket"
|
||
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 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.uyarilar?.length ? (
|
||
<ul className="flex flex-col gap-2">
|
||
{ozet.uyarilar.map((uyari) => (
|
||
<li
|
||
key={uyari}
|
||
className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900"
|
||
>
|
||
<AlertTriangle
|
||
className="mt-0.5 size-4 shrink-0"
|
||
aria-hidden
|
||
/>
|
||
{uyari}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
{ozet.kilitli ? (
|
||
<p className="flex items-center gap-1.5 text-xs text-slate-500">
|
||
<Lock className="size-3.5" aria-hidden />
|
||
{ozet.kilitliUyariSayisi ? (
|
||
<>
|
||
{ozet.kilitliUyariSayisi} uyarı daha kilitli —
|
||
sıralamana özel uyarıların pakette.
|
||
</>
|
||
) : (
|
||
<>
|
||
Değerlendirmenin tamamı ve sıralamana özel uyarılar
|
||
pakette.
|
||
</>
|
||
)}
|
||
</p>
|
||
) : null}
|
||
</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) =>
|
||
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">
|
||
<DanismanAvatar />
|
||
{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">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" 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>
|
||
);
|
||
}
|