feat: update app.db and BACKLOG.md with new decisions and adjustments
All checks were successful
Deploy / deploy (push) Successful in 7m45s

- Updated the app.db file to reflect recent changes in the application state.
- Modified BACKLOG.md to include new entries regarding the removal of certain wizard steps, adjustments to priority chips, and the introduction of special quota conditions for candidates.
- Added details on the decision to link priority chips to data sources and clarified the implications for user experience and data handling.
This commit is contained in:
bilalgursen
2026-10-06 17:17:49 +03:00
parent c0696e2cbc
commit a3cb058a3b
74 changed files with 10334 additions and 445 deletions

View File

@@ -0,0 +1,173 @@
"use client";
// Danışman veriye bakarken cevabın üstünde duran araç hapları (DESIGN.md
// §4.20). Durum modeli prompt-kit'in `Tool` bileşeniyle aynıdır (AI SDK araç
// parçası: input-streaming → input-available → output-available | output-error)
// ama o bileşen ham araç adını, İngilizce rozeti ve JSON girdi/çıktıyı
// gösterir — öğrenciye araç adı ve iç kimlik gösterilmez (danisman-prompt.ts
// kuralı), bu yüzden hap burada öğrenci diliyle yazılır. Aynı araç bir cevapta
// birkaç kez çağrılırsa tek hap, yanında sayı. Haplar cevap bittikten sonra da
// kalır: danışmanın neye baktığı görünür olsun (güven). Kayıtlı geçmişte araç
// parçası tutulmadığı için haplar yalnız canlı cevapta vardır.
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, CircleAlert, Loader2 } from "lucide-react";
import type { UIMessage } from "ai";
import { Steps, StepsContent, StepsItem, StepsTrigger } from "@/components/ui/steps";
export type AracDurumu = "calisiyor" | "bitti" | "hata";
export type AracPili = {
/** Araç adı (ARAC_ETIKETI anahtarı); hap anahtarı olarak da kullanılır */
arac: string;
durum: AracDurumu;
/** Aynı aracın bu cevaptaki çağrı sayısı */
sayi: number;
};
/** Araç adı → öğrenci dili: sürerken ve bitince (araclar.ts ile aynı adlar). */
const ARAC_ETIKETI: Record<string, { surer: string; bitti: string }> = {
program_ara: { surer: "Programlara bakıyorum", bitti: "Programlara baktım" },
program_detay: { surer: "Programın ayrıntısına bakıyorum", bitti: "Programın ayrıntısına baktım" },
program_netleri: { surer: "Netlere bakıyorum", bitti: "Netlere baktım" },
program_karsilastir: { surer: "Programları karşılaştırıyorum", bitti: "Programları karşılaştırdım" },
bolum_istihdam: { surer: "Mezunların iş durumuna bakıyorum", bitti: "Mezunların iş durumuna baktım" },
bolum_ozeti: { surer: "Bölüme bakıyorum", bitti: "Bölüme baktım" },
il_ozeti: { surer: "Şehirdeki üniversitelere bakıyorum", bitti: "Şehirdeki üniversitelere baktım" },
rehber_ara: { surer: "Rehber yazılarına bakıyorum", bitti: "Rehber yazılarına baktım" },
rehber_oku: { surer: "Rehber yazısını okuyorum", bitti: "Rehber yazısını okudum" },
tercih_takvimi: { surer: "Takvime bakıyorum", bitti: "Takvime baktım" },
universite_profili: { surer: "Üniversiteye bakıyorum", bitti: "Üniversiteye baktım" },
universite_siralamasi: { surer: "Üniversite sıralamasına bakıyorum", bitti: "Üniversite sıralamasına baktım" },
kapanan_program_ara: { surer: "Kapanan programlara bakıyorum", bitti: "Kapanan programlara baktım" },
profil_guncelle: { surer: "Tercihini kaydediyorum", bitti: "Tercihini kaydettim" },
};
const VARSAYILAN_ETIKET = { surer: "Veriye bakıyorum", bitti: "Veriye baktım" };
/** Hapın metni; hata renkle değil sözle söylenir (§2.2). */
export function aracPiliMetni(p: AracPili): string {
const e = ARAC_ETIKETI[p.arac] ?? VARSAYILAN_ETIKET;
const sayi = p.sayi > 1 ? ` ×${p.sayi}` : "";
if (p.durum === "calisiyor") return `${e.surer}${sayi}…`;
if (p.durum === "hata") return `${e.bitti}${sayi} (veri gelmedi)`;
return `${e.bitti}${sayi}`;
}
/**
* Mesajın araç parçalarını araç başına tek hapa indirger: biri hâlâ sürüyorsa
* "çalışıyor", hiçbiri sürmüyor ama biri hata verdiyse "hata", yoksa "bitti".
* Sıra ilk görülme sırasıdır; haplar yer değiştirmez.
*/
export function aracPilleri(m: UIMessage): AracPili[] {
const sira: string[] = [];
const toplam = new Map<string, { calisiyor: number; hata: number; sayi: number }>();
for (const p of m.parts) {
if (!p.type.startsWith("tool-") || !("state" in p)) continue;
const arac = p.type.slice("tool-".length);
let t = toplam.get(arac);
if (!t) {
t = { calisiyor: 0, hata: 0, sayi: 0 };
toplam.set(arac, t);
sira.push(arac);
}
t.sayi += 1;
if (p.state === "output-error") t.hata += 1;
else if (p.state !== "output-available") t.calisiyor += 1;
}
return sira.map((arac) => {
const t = toplam.get(arac)!;
const durum: AracDurumu = t.calisiyor > 0 ? "calisiyor" : t.hata > 0 ? "hata" : "bitti";
return { arac, durum, sayi: t.sayi };
});
}
/** Danışman veriye bakarken görünen satır: kompakt çipler (§4.17 kalıbı, h-8). */
const PIL =
"inline-flex h-8 items-center gap-1.5 rounded-full border border-slate-200 bg-card pl-2.5 pr-3 text-xs font-semibold text-slate-700";
function DurumIkonu({ durum }: { durum: AracDurumu }) {
if (durum === "calisiyor") {
return (
<Loader2
className="size-3.5 shrink-0 animate-spin text-primary motion-reduce:animate-none"
aria-hidden
/>
);
}
if (durum === "hata") {
return <CircleAlert className="size-3.5 shrink-0 text-red-600" aria-hidden />;
}
return <Check className="size-3.5 shrink-0 text-emerald-600" aria-hidden />;
}
/**
* Birden çok araç varsa prompt-kit `Steps` düzeni (Bilal, 6 Eki 2026: "Steps de
* olsun"): başlık satırı sürerken "Veriye bakıyorum · 2/3", bitince "3 adımda
* baktım"; açılır gövdede her adım ikon + metin, solda ince çubuk. Tek araçta
* hap yeterli. Açık başlar; kullanıcı kapatabilir.
*/
function AracAdimlari({ piller }: { piller: AracPili[] }) {
const biten = piller.filter((p) => p.durum !== "calisiyor").length;
const suruyor = biten < piller.length;
return (
<Steps defaultOpen className="px-0.5">
<StepsTrigger
leftIcon={<DurumIkonu durum={suruyor ? "calisiyor" : piller.some((p) => p.durum === "hata") ? "hata" : "bitti"} />}
className="text-xs font-semibold"
aria-live="polite"
>
{suruyor ? `Veriye bakıyorum · ${biten}/${piller.length}` : `${piller.length} adımda baktım`}
</StepsTrigger>
<StepsContent>
{piller.map((p) => (
<StepsItem key={p.arac} className="flex items-center gap-1.5 text-xs font-medium text-slate-700">
<DurumIkonu durum={p.durum} />
{aracPiliMetni(p)}
</StepsItem>
))}
</StepsContent>
</Steps>
);
}
export function AracPilleri({ piller }: { piller: AracPili[] }) {
const hareketAz = useReducedMotion();
if (piller.length === 0) return null;
if (piller.length > 1) return <AracAdimlari piller={piller} />;
return (
// Odak almayan durum değişikliği ekran okuyucuya duyurulur (§10.6)
<ul role="status" aria-live="polite" className="flex flex-wrap gap-1.5 px-0.5">
<AnimatePresence initial={false}>
{piller.map((p) => (
// Hap aşağıdan gelir, bitince yerinde kalır (§6: giriş-çıkış aynı yol,
// sekmesiz yay); "hareketi azalt"ta yalnız opaklık.
<motion.li
key={p.arac}
layout={!hareketAz}
initial={hareketAz ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={hareketAz ? { opacity: 0 } : { opacity: 0, y: 4 }}
transition={{ type: "spring", bounce: 0, duration: 0.3 }}
className={PIL}
>
{/* İkon durumla birlikte yumuşakça değişir; metin aynı yerde kalır */}
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={p.durum}
initial={{ opacity: 0, scale: hareketAz ? 1 : 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: hareketAz ? 1 : 0.6 }}
transition={{ type: "spring", bounce: 0, duration: 0.2 }}
className="flex"
>
<DurumIkonu durum={p.durum} />
</motion.span>
</AnimatePresence>
{aracPiliMetni(p)}
</motion.li>
))}
</AnimatePresence>
</ul>
);
}

View File

@@ -463,7 +463,7 @@ function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) {
<div className="flex flex-col gap-4 p-4">
<Message className="justify-start">
{/* Liste üretimi de bir bekleme hâli: avatar, sohbetteki
"cevap yazılıyor" durumuyla aynı nabzı atar. */}
"cevap yazılıyor" durumuyla aynı animasyonu oynatır. */}
<DanismanAvatar canli />
<div className="flex max-w-[85%] flex-col gap-2">
{/* MessageContent'in class setiyle aynı balon; shimmer

View File

@@ -6,6 +6,7 @@
import { useMemo, useRef, useState } from "react";
import Link from "next/link";
import { animate, useReducedMotion } from "motion/react";
import {
ArrowRight,
Bot,
@@ -21,6 +22,7 @@ import { Button } from "@/components/ui/button";
import { RaporListesi } from "./rapor-listesi";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { tercihFarki } from "@/features/rapor/tercih-farki";
import { listeUyarilari } from "@/features/rapor/liste-uyarilari";
import { TercihDegistiModali } from "./tercih-degisti-modali";
import { TercihHaritasiLazy } from "@/features/liste/components/tercih-haritasi-lazy";
import { YurtdisiHaritalarLazy } from "@/features/liste/components/yurtdisi-haritalar-lazy";
@@ -28,6 +30,7 @@ import { pinleriTuret } from "@/lib/harita-pinler";
import type { RaporParams, RaporSonuc } from "@/features/rapor/types/rapor";
import {
SohbetClient,
SOHBET_GENISLEME_GECISI,
type DanismanOzeti,
type IlkTercihYorumu,
type Mesaj,
@@ -39,6 +42,7 @@ import {
useTercihProfili,
} from "@/features/liste/hooks/use-tercih-profili";
import { UNIVERSITE_TIPI_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
import { ozelDurumOzeti } from "@/lib/ozel-durum";
import { PUAN_TURLERI } from "@/types/yokatlas";
import { DILIM_ETIKET } from "@/lib/risk";
@@ -92,6 +96,40 @@ export function ListemGovde({
const [acikSira, setAcikSira] = useState<number | null>(null);
const haritaRef = useRef<HTMLElement>(null);
// Genişletilmiş sohbet (DESIGN.md §4.20): panel boyunu kendi uzatır; burada
// ızgaranın sohbet sütunu aynı yayla büyür, liste ve harita daralan sütunda
// yeniden akar. Telefonda (lg altı) ızgara tek sütun, yalnız boy değişir.
const [sohbetGenis, setSohbetGenis] = useState(false);
const izgaraRef = useRef<HTMLDivElement>(null);
const hareketAz = useReducedMotion();
function sohbetGenisligiAyarla(genis: boolean) {
setSohbetGenis(genis);
const el = izgaraRef.current;
if (!el || !window.matchMedia("(min-width: 1024px)").matches) return;
// Dar sütun genişliği ızgara sınıflarıyla aynı: lg 380, xl 440
const taban = window.matchMedia("(min-width: 1280px)").matches ? 440 : 380;
const hedef = genis
? Math.max(taban, Math.round(Math.min(el.clientWidth * 0.6, 760)))
: taban;
const simdiki = parseFloat(el.style.getPropertyValue("--sohbet-kolon")) || taban;
const bitir = () => {
// Dar hâlde satır içi değer kalkar; kırılma noktası değişince sınıf geçerli olur
if (genis) el.style.setProperty("--sohbet-kolon", `${hedef}px`);
else el.style.removeProperty("--sohbet-kolon");
};
if (hareketAz) {
bitir();
return;
}
el.style.setProperty("--sohbet-kolon", `${simdiki}px`);
void animate(
el,
{ "--sohbet-kolon": `${hedef}px` } as Record<string, string>,
SOHBET_GENISLEME_GECISI,
).then(bitir);
}
const pinVerisi = useMemo(
() =>
pinleriTuret(
@@ -108,6 +146,10 @@ export function ListemGovde({
return `${rapor.tercihler.length} tercih: ${say("hayal")} ${DILIM_ETIKET.hayal.toLocaleLowerCase("tr-TR")} · ${say("dengeli")} ${DILIM_ETIKET.dengeli.toLocaleLowerCase("tr-TR")} · ${say("garanti")} ${DILIM_ETIKET.garanti.toLocaleLowerCase("tr-TR")}`;
}, [rapor]);
// Kapsam / tek şehir uyarıları listenin üstünde değil, danışmanın ağzından
// sohbetin açılışında verilir (Bilal, 6 Eki 2026).
const uyarilar = useMemo(() => listeUyarilari(rapor), [rapor]);
// Profil client'ta (localStorage) çözülür: ilk render'da null döner, yani
// SSR ile aynı "Tercihlerimi düzenle" hâli basılır — hidrasyon uyuşmazlığı
// olmaz, fark varsa buton sonraki render'da "güncelle"ye döner.
@@ -125,14 +167,14 @@ export function ListemGovde({
const satirlar = [
`Başarı sıralaman ${adaySira.toLocaleString("tr-TR")} ve puan türün ${PUAN_TURLERI[params.tur]}`,
hasPaket
? "24'lük listenin tamamı: program, üniversite, şehir, taban sıralaması ve risk etiketi"
: `Listenin açık ilk ${acikSatir} satırı — kilitli tercihleri danışman da görmüyor`,
? "24'lük listenin tamamı: program, üniversite, şehir, taban sıralaması, risk etiketi, her satırın gerekçesi ve öne çıkan verileri; listenin genel değerlendirmesi ve uyarıları"
: `Listenin açık ilk ${acikSatir} satırı (gerekçeleriyle) — kilitli tercihleri ve genel değerlendirmeyi danışman da görmüyor`,
];
satirlar.push(
"Sohbette söylediğin ve \"Kaydettiklerim\" çiplerinde duran tercihler (ücret, il/km, dil, burs, öğretim türü, kampüs)",
"Sohbette söylediğin ve \"Kaydettiklerim\" çiplerinde duran tercihler (ücret, il/km, dil, burs, öğretim türü, kampüs, özel kontenjan durumu)",
);
satirlar.push(
"Sorunca veriden okur: taban sıraları, kontenjan, ücret/burs, dil, akreditasyon, koşullar, üniversite künyesi",
"Sorunca veriden okur: yıllara göre taban sırası, puan, kontenjan ve yerleşen; kontenjan türleri; ücret/burs, dil, akreditasyon, kadro, koşullar; netler; mezun istihdamı (TÜİK); üniversite künyesi ve URAP sıralaması; kapanan programlar; rehber yazıları",
);
if (params.kategoriler?.length) {
satirlar.push(`İlgi alanların: ${params.kategoriler.join(", ")}`);
@@ -148,6 +190,9 @@ export function ListemGovde({
if (params.oncelikler?.length) {
satirlar.push(`Önceliklerin: ${params.oncelikler.join(", ")}`);
}
if (params.ozelDurum?.length) {
satirlar.push(`Özel kontenjan durumun: ${ozelDurumOzeti(params.ozelDurum)}`);
}
satirlar.push("Bu sohbetteki son mesajların");
return satirlar;
}, [adaySira, params, hasPaket, acikSatir]);
@@ -167,7 +212,13 @@ export function ListemGovde({
);
return (
<div className="mt-8 grid grid-cols-[minmax(0,1fr)] items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px] xl:grid-cols-[minmax(0,1fr)_440px]">
// Sohbet sütunu `--sohbet-kolon` ile (lg 380, xl 440 px = SOHBET_KOLON_TABANI);
// "Genişlet" satır içi değişkeni yayla büyütür (sohbetGenisligiAyarla), liste
// ve harita daralan 1fr sütunda kendini yeniden akıtır. DESIGN.md §4.20.
<div
ref={izgaraRef}
className="mt-8 grid grid-cols-[minmax(0,1fr)] items-start gap-6 lg:[--sohbet-kolon:380px] lg:grid-cols-[minmax(0,1fr)_var(--sohbet-kolon)] xl:[--sohbet-kolon:440px]"
>
<div className="flex min-w-0 flex-col gap-6">
{/* Harita: listenin coğrafyası — ile dokun, üniversiteleri gör */}
<section
@@ -330,8 +381,11 @@ export function ListemGovde({
initialMesajlar={sohbetGecmisi}
initialProfil={danismanProfili}
ozet={ozet}
uyarilar={uyarilar}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri}
genis={sohbetGenis}
onGenisDegistir={sohbetGenisligiAyarla}
/>
) : (
<div className="flex flex-col gap-6">
@@ -340,11 +394,14 @@ export function ListemGovde({
<SohbetClient
baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi}
initialProfil={danismanProfili}
initialProfil={danismanProfili}
ozet={ozet}
uyarilar={uyarilar}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri}
className="h-[480px]"
genis={sohbetGenis}
onGenisDegistir={sohbetGenisligiAyarla}
/>
<section

View File

@@ -46,6 +46,7 @@ import {
SATIR_MOBIL_SAYI,
} from "@/components/liste-satiri";
import { VERI_YIL_SAYISI } from "@/lib/veri-yillari";
import { ProgramAyrintiButonu } from "@/features/katalog/components/program-ayrinti";
import type { RaporSonuc } from "@/features/rapor/types/rapor";
import {
DILIM_ETIKET,
@@ -79,6 +80,7 @@ export function RaporSatirDetayi({
gerekce,
riskNotu,
trendOzeti,
oneCikan,
}: {
dilim: string;
risk: RiskSeviyesi;
@@ -86,6 +88,8 @@ export function RaporSatirDetayi({
gerekce: string;
riskNotu: string;
trendOzeti: string;
/** Koddan hesaplanan "öne çıkan" veriler (uyum puanı); yoksa satır çizilmez */
oneCikan?: string[];
}) {
const stil = RISK_STIL[risk];
// Risk etiketi yalnızca dilimin varsayılanından farklıysa yazılır; yoksa
@@ -109,6 +113,12 @@ export function RaporSatirDetayi({
Neden listede?
</dt>
<dd className="mt-1 text-slate-600">{gerekce}</dd>
{oneCikan && oneCikan.length > 0 ? (
<dd className="mt-2 text-sm text-slate-500">
<span className="font-semibold text-slate-700">Öne çıkan:</span>{" "}
{oneCikan.join(" · ")}
</dd>
) : null}
</div>
<div className="rounded-xl border border-slate-200 bg-card p-4">
<dt className="text-sm font-semibold text-slate-900">
@@ -170,15 +180,6 @@ export function RaporListesi({
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
const tekIl = useMemo(() => {
const iller = new Set<string>();
for (const t of rapor.tercihler) {
const il = rapor.programlar[t.programId]?.il;
if (il) iller.add(il);
}
return iller.size === 1 ? [...iller][0] : null;
}, [rapor]);
// Sütun başlığı kaydın KENDİ penceresini söyler: eski raporların sıra
// geçmişi üretildiği günün yıl sayısını taşır (dizi doldurulmaz), başlık
// çizgide olmayan bir yılı vaat etmesin. Geçmişi hiç olmayan kayıtta
@@ -356,18 +357,27 @@ export function RaporListesi({
gerekce={t.gerekce}
riskNotu={t.riskNotu}
trendOzeti={t.trendOzeti}
oneCikan={t.oneCikan}
/>
)}
{onHaritadaGor && p?.il ? (
<button
type="button"
onClick={() => onHaritadaGor(p.il, t.sira)}
className="mt-2 inline-flex min-h-11 cursor-pointer items-center gap-1.5 text-sm font-semibold text-primary transition-colors duration-150 hover:text-orange-500"
>
<MapPin className="size-4" aria-hidden />
Haritada gör
</button>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-x-5">
<ProgramAyrintiButonu
programId={t.programId}
isim={p?.isim ?? t.programId}
universite={p?.universite}
varyant="metin"
/>
{onHaritadaGor && p?.il ? (
<button
type="button"
onClick={() => onHaritadaGor(p.il, t.sira)}
className="inline-flex min-h-11 cursor-pointer items-center gap-1.5 text-sm font-semibold text-primary transition-colors duration-150 hover:text-orange-500"
>
<MapPin className="size-4" aria-hidden />
Haritada gör
</button>
) : null}
</div>
</TableCell>
</TableRow>
) : null}
@@ -383,23 +393,8 @@ export function RaporListesi({
</p>
) : null}
{rapor.kapsam?.uyarlandi ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-relaxed text-amber-900">
Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu; listen{" "}
{rapor.kapsam.dagilim.hayal} hayal · {rapor.kapsam.dagilim.dengeli}{" "}
dengeli · {rapor.kapsam.dagilim.garanti} güvenli olarak kuruldu. Daha
fazla seçenek için alan veya il filtreni genişletebilirsin.
</p>
) : null}
{tekIl ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-relaxed text-amber-900">
Listenin {rapor.tercihler.length} tercihi de tek şehirde (
{trBaslikDuzeni(tekIl)}). Boşta kalma riskini azaltmak için
birkaç farklı şehir eklemeyi düşünebilirsin.
</p>
) : null}
{/* Kapsam ve tek şehir uyarıları burada çizilmez: danışman sohbetinin
açılışında "Danışman uyarısı" olarak verilir (liste-uyarilari.ts). */}
<div className="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-card">
<Table>
<TableHeader>

View File

@@ -0,0 +1,372 @@
"use client";
// Danışman cevabındaki bağlantılar (DESIGN.md §4.20). Site içi adres
// (/rehber/…, /universite/…, /bolum/…, /universiteler/<il>, /paket, /listem)
// prompt-kit'in `Source` bileşenindeki gibi KÜÇÜK bir satır içi hap olarak
// çizilir: türünü söyleyen ikon + sayfanın adı, hover tek turuncu (§2.1).
// Markdown'da `[Başlık](/rehber/slug)` biçimi beklenir (danisman-prompt.ts
// kuralı); model adresi düz metin ya da backtick içinde yazarsa aynı hap, adı
// slug'dan türetilerek çizilir — Bilal, 6 Eki 2026: "chat link verdiğinde kod
// gibi veriyor". Dış bağlantı (https://…) yeni sekmede açılan düz linktir.
//
// Üzerine gelince (yalnız fare/iz sürücü olan cihazlarda) imleci izleyen bir
// SAYFA ÖNİZLEMESİ açılır: aramadaki mini tarayıcı penceresi dilinde
// (katalog-arama.tsx `KESFET_TONLARI`) bir pencere — üç nokta, adres hapı,
// içinde rehberde yalnız kapak (tam 1200×630 ölçeklenir), diğerlerinde
// logo/ikon + başlık + özet. Veri
// /api/onizleme'den gelir, hap başına bir kez çekilir. Hareket motion-primitives
// `Cursor` kalıbı (Bilal'in verdiği örnek): imleç yaylı izlenir, pencere
// yükseklik 0 → auto, ölçek 0,3 → 1 ile açılır, aynı yoldan kapanır;
// hareketi azalt'ta yalnız opaklık ve izleme yok. Dokunmatikte önizleme yok.
// prompt-kit `Source` birebir alınmadı: Google favicon'u ve `muted` paleti
// dış siteler içindir.
import {
useCallback,
useRef,
useState,
type ComponentProps,
type ComponentType,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import {
BookOpen,
Coins,
ExternalLink,
GraduationCap,
Landmark,
ListChecks,
MapPin,
type LucideIcon,
} from "lucide-react";
import type { Components, ExtraProps } from "react-markdown";
import type { SayfaOnizlemesi } from "@/app/api/onizleme/route";
import { INITIAL_COMPONENTS } from "@/components/ui/markdown";
import { UniLogo } from "@/components/uni-logo";
import { RehberKapak } from "@/features/rehber/components/rehber-kapak";
import { cn } from "@/lib/utils";
/** Site içi adres: kök yol ya da canlı alan adıyla yazılmış hâli. */
const SITE_KOKLERI = ["https://kolaytercih.com", "https://www.kolaytercih.com"];
/** Metinde geçen çıplak site içi adres (bağlantı/backtick dışında). */
const CIPLAK_ADRES =
/(^|[^\w/(\]`])(\/(?:rehber|universite|bolum|universiteler)\/[a-z0-9-]+|\/(?:paket|listem|sonuc|meraklisina|bolumler|universiteler))(?![\w/?-])/g;
export function icYolaCevir(href: string): string | null {
const h = href.trim();
for (const kok of SITE_KOKLERI) {
if (h.startsWith(kok)) return h.slice(kok.length) || "/";
}
if (h.startsWith("/") && !h.startsWith("//")) return h;
return null;
}
type Tur = {
ikon: LucideIcon;
/** Ekran okuyucu ve title için: "Rehber yazısı: …" */
etiket: string;
/** Önizleme verisi var mı (api/onizleme bu yolu tanır) */
onizleme: boolean;
};
const TURLER: { desen: RegExp; tur: Tur }[] = [
{ desen: /^\/rehber\/[a-z0-9-]+$/, tur: { ikon: BookOpen, etiket: "Rehber yazısı", onizleme: true } },
{ desen: /^\/universite\/[a-z0-9-]+$/, tur: { ikon: Landmark, etiket: "Üniversite sayfası", onizleme: true } },
{ desen: /^\/bolum\/[a-z0-9-]+$/, tur: { ikon: GraduationCap, etiket: "Bölüm sayfası", onizleme: true } },
{ desen: /^\/universiteler\/[a-z0-9-]+$/, tur: { ikon: MapPin, etiket: "Şehir sayfası", onizleme: true } },
{ desen: /^\/paket/, tur: { ikon: Coins, etiket: "Paket", onizleme: false } },
{ desen: /^\/listem/, tur: { ikon: ListChecks, etiket: "Listem", onizleme: false } },
];
function turunu(yol: string): Tur {
return (
TURLER.find((t) => t.desen.test(yol))?.tur ?? { ikon: ExternalLink, etiket: "Sayfa", onizleme: false }
);
}
/** Başlıksız adres için slug'dan okunur ad: "vakif-universitesi-bursu-kesilir-mi" → "Vakif üniversitesi bursu kesilir mi". */
export function slugBasligi(yol: string): string {
const slug = yol.split(/[?#]/)[0].split("/").filter(Boolean).pop() ?? yol;
const ad = slug.replace(/-/g, " ").replace(/universite/g, "üniversite").trim();
return ad ? ad.charAt(0).toLocaleUpperCase("tr-TR") + ad.slice(1) : yol;
}
/** Çıplak site içi adresleri markdown bağlantısına çevirir; hazır bağlantı ve backtick'e dokunmaz. */
export function baglantilariIsaretle(metin: string): string {
return metin.replace(CIPLAK_ADRES, (_m, once: string, yol: string) => `${once}[${yol}](${yol})`);
}
// ── Önizleme verisi (hap başına bir kez) ────────────────────────────────────
const onizlemeOnbellek = new Map<string, Promise<SayfaOnizlemesi | null>>();
function onizlemeGetir(yol: string): Promise<SayfaOnizlemesi | null> {
let p = onizlemeOnbellek.get(yol);
if (!p) {
p = fetch(`/api/onizleme?yol=${encodeURIComponent(yol)}`)
.then((r) => (r.ok ? (r.json() as Promise<SayfaOnizlemesi>) : null))
.catch(() => null);
onizlemeOnbellek.set(yol, p);
}
return p;
}
/** Fare/iz sürücü var mı; dokunmatikte önizleme açılmaz. */
function hoverVar(): boolean {
return typeof window !== "undefined" && window.matchMedia("(hover: hover) and (pointer: fine)").matches;
}
// ── Pencere (mini tarayıcı) ─────────────────────────────────────────────────
const PENCERE_GENISLIK = 288;
const KAPAK_GENISLIK = 1200;
const KAPAK_YUKSEKLIK = 630;
const KAPAK_OLCEK = PENCERE_GENISLIK / KAPAK_GENISLIK;
const KAPAK_GOSTERIM_YUKSEKLIK = KAPAK_YUKSEKLIK * KAPAK_OLCEK;
/** Rehber: çubuk 24 + ölçekli kapak. Diğer türler metinli gövde. */
const PENCERE_YUKSEKLIK_TAHMINI = 24 + KAPAK_GOSTERIM_YUKSEKLIK;
const IMLEC_PAYI = 18;
/** Tam kapak (1200×630) pencereye ölçeklenir — compact/vw kesmez, tüm yazı ve logo görünür. */
function OlcekliKapak({ baslik }: { baslik: string }) {
return (
<div
className="relative overflow-hidden"
style={{ width: PENCERE_GENISLIK, height: KAPAK_GOSTERIM_YUKSEKLIK }}
>
<div
className="absolute left-0 top-0 origin-top-left"
style={{
width: KAPAK_GENISLIK,
height: KAPAK_YUKSEKLIK,
transform: `scale(${KAPAK_OLCEK})`,
}}
>
<RehberKapak
baslik={baslik}
className="h-full rounded-none border-0"
/>
</div>
</div>
);
}
function PencereIcerigi({ yol, baslik, veri }: { yol: string; baslik: ReactNode; veri: SayfaOnizlemesi | null | undefined }) {
const tur = turunu(yol);
const Ikon = tur.ikon;
const gorunenBaslik = veri?.baslik ?? (typeof baslik === "string" ? baslik : slugBasligi(yol));
const rehber = tur.etiket === "Rehber yazısı";
return (
<div className="overflow-hidden rounded-xl border border-slate-200/80 bg-card shadow-lg shadow-slate-900/5">
{/* Pencere çubuğu: aramadaki mini tarayıcıyla aynı dil (üç nokta + adres hapı) */}
<div className="flex h-6 shrink-0 items-center gap-1 border-b border-slate-100/90 bg-slate-50/90 px-2.5">
<span className="size-2 rounded-full bg-slate-300" />
<span className="size-2 rounded-full bg-slate-300 opacity-65" />
<span className="size-2 rounded-full bg-slate-300 opacity-35" />
<span className="ml-2 flex h-4 min-w-0 flex-1 items-center rounded-full bg-card px-2 text-xs text-slate-500 shadow-[0_0_0_1px_rgba(15,23,42,0.06)]">
<span className="truncate">kolaytercih.com{yol}</span>
</span>
</div>
{rehber ? (
<OlcekliKapak baslik={gorunenBaslik} />
) : (
<div className="p-3">
{veri?.gorsel && veri.tur === "universite" ? (
<div className="mb-3 flex items-center gap-3">
<UniLogo slug={yol.split("/").pop()} ad={gorunenBaslik} boy="md" />
</div>
) : (
<div className="mb-3 flex size-11 items-center justify-center rounded-xl bg-primary/10">
<Ikon className="size-5 text-primary" aria-hidden />
</div>
)}
<p className="font-heading text-sm font-bold leading-snug text-slate-900 line-clamp-2">
{gorunenBaslik}
</p>
{veri ? (
<>
<p className="mt-1 text-xs leading-5 text-slate-500 line-clamp-3">{veri.aciklama}</p>
{veri.meta.length ? (
<p className="mt-2 text-xs font-medium text-slate-500">{veri.meta.join(" · ")}</p>
) : null}
</>
) : (
<p className="mt-1 text-xs leading-5 text-slate-500">{tur.etiket}</p>
)}
</div>
)}
</div>
);
}
/**
* İmleci izleyen önizleme (motion-primitives `Cursor` kalıbı). Body'ye
* portal'lanır: sohbet kabı overflow-hidden ve pencere kabın dışına taşar.
*/
function ImlecOnizlemesi({
acik,
goruldu,
x,
y,
yol,
baslik,
veri,
}: {
acik: boolean;
/** İlk hover'dan sonra true: portal yalnız istemcide, olaydan sonra kurulur (effect'te setState yok) */
goruldu: boolean;
x: ReturnType<typeof useSpring>;
y: ReturnType<typeof useSpring>;
yol: string;
baslik: ReactNode;
veri: SayfaOnizlemesi | null | undefined;
}) {
const hareketAz = useReducedMotion();
if (!goruldu || typeof document === "undefined") return null;
return createPortal(
<AnimatePresence>
{acik ? (
<motion.div
aria-hidden
style={{ x, y, width: PENCERE_GENISLIK }}
className="pointer-events-none fixed left-0 top-0 z-[60]"
>
<motion.div
initial={hareketAz ? { opacity: 0 } : { height: 0, opacity: 0, scale: 0.3 }}
animate={hareketAz ? { opacity: 1 } : { height: "auto", opacity: 1, scale: 1 }}
exit={hareketAz ? { opacity: 0 } : { height: 0, opacity: 0, scale: 0.3 }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="origin-top-left overflow-hidden"
>
<PencereIcerigi yol={yol} baslik={baslik} veri={veri} />
</motion.div>
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}
// ── Hap ─────────────────────────────────────────────────────────────────────
/** Küçük satır içi hap (Bilal, 6 Eki 2026: ilk sürüm "çok yer kaplıyor"). */
const HAP =
"inline-flex h-6 max-w-full cursor-pointer items-center gap-1 rounded-full border border-slate-200 bg-card pl-1.5 pr-2 align-middle text-xs font-medium text-primary no-underline transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]";
export function SohbetBaglantisi({
href,
children,
className,
}: {
href: string;
children?: ReactNode;
className?: string;
}) {
const yol = icYolaCevir(href);
const hareketAz = useReducedMotion();
const [acik, setAcik] = useState(false);
const [goruldu, setGoruldu] = useState(false);
const [veri, setVeri] = useState<SayfaOnizlemesi | null | undefined>(undefined);
const yuklendi = useRef(false);
// İmleç konumu: ham değer → yay (hafif gecikmeyle izler, Cursor kalıbı)
const hamX = useMotionValue(0);
const hamY = useMotionValue(0);
const yay = { stiffness: 450, damping: 40, mass: 0.5 };
const x = useSpring(hamX, yay);
const y = useSpring(hamY, yay);
const konumla = useCallback(
(e: ReactPointerEvent, zipla: boolean) => {
// Pencere imlecin sağ altında; kenara sığmazsa sola / üste alınır
let px = e.clientX + IMLEC_PAYI;
let py = e.clientY + IMLEC_PAYI;
if (px + PENCERE_GENISLIK > window.innerWidth - 8) px = e.clientX - IMLEC_PAYI - PENCERE_GENISLIK;
if (py + PENCERE_YUKSEKLIK_TAHMINI > window.innerHeight - 8) py = e.clientY - IMLEC_PAYI - PENCERE_YUKSEKLIK_TAHMINI;
if (zipla || hareketAz) {
hamX.jump(px);
hamY.jump(py);
} else {
hamX.set(px);
hamY.set(py);
}
},
[hamX, hamY, hareketAz],
);
if (!yol) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"font-medium text-primary underline underline-offset-2 transition-colors hover:text-orange-500",
className,
)}
>
{children}
</a>
);
}
const tur = turunu(yol);
// Bağlantı metni adresin kendisiyse (çıplak ya da backtick'li adres) adı slug'dan türet
const metin =
typeof children === "string" && icYolaCevir(children) === yol ? slugBasligi(yol) : children;
const girdi = (e: ReactPointerEvent) => {
if (!tur.onizleme || !hoverVar()) return;
konumla(e, true);
setGoruldu(true);
setAcik(true);
if (!yuklendi.current) {
yuklendi.current = true;
void onizlemeGetir(yol).then(setVeri);
}
};
return (
<>
<Link
href={yol}
className={cn(HAP, className)}
title={tur.etiket}
onPointerEnter={girdi}
onPointerMove={(e) => acik && konumla(e, false)}
onPointerLeave={() => setAcik(false)}
onFocus={() => setAcik(false)}
>
<tur.ikon className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">{metin ?? slugBasligi(yol)}</span>
<span className="sr-only"> ({tur.etiket})</span>
</Link>
{tur.onizleme ? (
<ImlecOnizlemesi acik={acik} goruldu={goruldu} x={x} y={y} yol={yol} baslik={metin} veri={veri} />
) : null}
</>
);
}
type KodProps = ComponentProps<"code"> & ExtraProps;
const VarsayilanKod = INITIAL_COMPONENTS.code as ComponentType<KodProps>;
function InlineKod(props: KodProps) {
// Backtick içine alınmış site içi adres de haptır; gerisi prompt-kit'in satır içi kodu
const metin = typeof props.children === "string" ? props.children.trim() : null;
const yol = metin ? icYolaCevir(metin) : null;
if (yol && /^\/[a-z]/.test(yol) && !/\s/.test(metin ?? "")) {
return <SohbetBaglantisi href={yol}>{yol}</SohbetBaglantisi>;
}
return <VarsayilanKod {...props} />;
}
/** Danışman balonunun markdown bileşenleri: prompt-kit varsayılanı + bağlantı hapları. */
export const SOHBET_MARKDOWN_BILESENLERI: Partial<Components> = {
...INITIAL_COMPONENTS,
a: ({ href, children }) => <SohbetBaglantisi href={href ?? "#"}>{children}</SohbetBaglantisi>,
code: InlineKod as Components["code"],
};

View File

@@ -5,11 +5,23 @@ import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type ChatTransport, type UIMessage } from "ai";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { ArrowUp, Coins, Info, Lock, Sparkles, Trash2, X } from "lucide-react";
import { animate, useReducedMotion } from "motion/react";
import {
ArrowUp,
Coins,
Info,
Lock,
Maximize2,
Minimize2,
Sparkles,
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
import { AracPilleri, aracPilleri } from "./arac-pilleri";
import { SOHBET_MARKDOWN_BILESENLERI, baglantilariIsaretle } from "./sohbet-baglanti";
import { Button } from "@/components/ui/button";
import {
ChatContainerContent,
@@ -67,31 +79,41 @@ function mesajMetni(m: UIMessage): string {
.join("\n\n");
}
/** Danışman veriye bakarken görünen satır (araç adı → öğrenci dili). */
const ARAC_ETIKETI: Record<string, string> = {
program_ara: "Programlara bakıyorum",
program_detay: "Programın ayrıntısına bakıyorum",
program_netleri: "Netlere bakıyorum",
program_karsilastir: "Programları karşılaştırıyorum",
bolum_istihdam: "Mezunların iş durumuna bakıyorum",
bolum_ozeti: "Bölüme bakıyorum",
il_ozeti: "Şehirdeki üniversitelere bakıyorum",
rehber_ara: "Rehber yazılarına bakıyorum",
tercih_takvimi: "Takvime bakıyorum",
universite_profili: "Üniversiteye bakıyorum",
profil_guncelle: "Tercihini kaydediyorum",
/** Soru gitti, cevabın ilk parçası henüz gelmedi: listede cevabın yerini
* tutan boş danışman mesajı. */
const BEKLENEN_CEVAP: UIMessage = {
id: "beklenen-cevap",
role: "assistant",
parts: [],
};
/** Şu an çalışan (sonucu henüz gelmemiş) araçların etiketleri. */
function calisanAraclar(m: UIMessage): string[] {
const etiketler = m.parts.flatMap((p) => {
if (!p.type.startsWith("tool-") || !("state" in p)) return [];
if (p.state === "output-available" || p.state === "output-error") return [];
return [ARAC_ETIKETI[p.type.slice("tool-".length)] ?? "Veriye bakıyorum"];
});
return [...new Set(etiketler)];
/** Danışman cevabının liste anahtarı: kendi kimliği değil, cevapladığı sorunun
* kimliği. Böylece bekleme satırı (BEKLENEN_CEVAP) ile sonradan gelen gerçek
* cevap React için aynı satırdır — avatarın animasyonu ve iskelet, ilk parça
* geldiğinde baştan başlamaz. */
function cevapAnahtari(mesajlar: UIMessage[], i: number): string {
const soru = mesajlar[i - 1];
return soru?.role === "user" ? `cevap-${soru.id}` : mesajlar[i].id;
}
// Araç adı → öğrenci dili eşlemesi ve "…bakıyorum" hapları arac-pilleri.tsx'te.
/** Panelin boyut geçişi: sekmesiz yay (DESIGN.md §6); listem-govde ızgarası aynı yayı kullanır. */
export const SOHBET_GENISLEME_GECISI = { type: "spring", bounce: 0, duration: 0.35 } as const;
/**
* Genişletilmiş panel üstten `top-28` (7rem; listem sütununun sticky ofseti)
* hizasına kaydırılır ve altı site geneli alt progressive blur'un
* (`.kt-sayfa-blur-alt`, 7rem; globals.css) üstünde biter — Bilal, 6 Eki 2026:
* "aşağı kısmı progressive blura takılıyor". Sayfa o kadar kaymıyorsa kalan
* fark boydan düşülür; panel hiçbir durumda bulanık şeride girmez.
*/
const USTE_UZAKLIK_REM = 7;
const ALT_BLUR_REM = 7;
const GENIS_EN_AZ_PX = 380;
/** /dev/sohbet gibi sticky'siz yerlerde de aynı hesap; sabit yükseklik istenirse `genisYukseklik`. */
const GENIS_YUKSEKLIK = "";
type Red = { status: number; error?: string };
/**
@@ -157,8 +179,8 @@ export interface IlkTercihYorumu {
/** 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. */
* kalın, boşluksuz). İşaret ortak AnimasyonluMark bileşeninin yerinde
* nabız varyantı, burada sürekli atar. */
export function DanismanBaslik() {
return (
// gap yok denecek kadar az (0.5): SVG viewBox'ının kendi iç boşluğu
@@ -174,25 +196,33 @@ export function DanismanBaslik() {
}
/** 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 }) {
* 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.
* `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({
canli = false,
aracta = false,
renk = "marka",
}: {
canli?: boolean;
aracta?: boolean;
renk?: "marka" | "turuncu";
}) {
return (
<span
className="mt-0.5 flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-slate-200 bg-card"
aria-hidden
>
{canli ? (
<AnimasyonluMark varyant="nabiz" className="size-5" />
) : (
<Image
src="/kolay-tercih-mark.svg"
alt=""
width={32}
height={32}
className="size-5"
/>
)}
<AnimasyonluMark
oynat={canli}
varyant={canli && aracta ? "zincir" : "nabiz"}
renk={renk}
className="size-5"
/>
</span>
);
}
@@ -224,10 +254,14 @@ export function SohbetClient({
initialMesajlar,
className,
ozet,
uyarilar,
ilkYorum,
bildikleri,
initialProfil,
denemeTransport,
genis: genisProp,
onGenisDegistir,
genisYukseklik = GENIS_YUKSEKLIK,
}: {
baslangicKredi: number;
/** Sunucu tarafında çekilmiş danışman profili; verilirse mount fetch'i atlanır */
@@ -238,6 +272,10 @@ export function SohbetClient({
className?: string;
/** Raporun genel değerlendirmesi; sohbetin açılış mesajı olarak gösterilir */
ozet?: DanismanOzeti;
/** Listenin kendisinden çıkan uyarılar (liste-uyarilari.ts: dağılım havuza
* uyarlandı, tüm tercihler tek şehirde). Özetin altında turuncu işaretli
* "Danışman uyarısı" balonu olarak durur; sohbet ilerlese de kalır. */
uyarilar?: string[];
/** 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ığı. */
@@ -248,6 +286,13 @@ export function SohbetClient({
/** Yalnız /dev/sohbet: /api/soru yerine önceden yazılmış senaryoyu oynatır
* (model, oturum ve kredi yok). Üretim yüzeyleri bunu vermez. */
denemeTransport?: ChatTransport<UIMessage>;
/** Genişletilmiş hâl dışarıdan yönetiliyorsa (listem ızgarası sütunu da
* büyütür); verilmezse panel kendi durumunu tutar ve yalnız boyu uzar. */
genis?: boolean;
onGenisDegistir?: (genis: boolean) => void;
/** Genişletilmiş panelin yüksekliği; verilmezse ekrana göre hesaplanır
* (üst 7rem sticky hizası, alt 7rem progressive blur payı). */
genisYukseklik?: string;
}) {
const [girdi, setGirdi] = useState("");
const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
@@ -259,6 +304,62 @@ export function SohbetClient({
// yeniden çekilir: model profil_guncelle çağırdıysa yeni çip burada belirir.
const [profil, setProfil] = useState<ProfilSatiri[]>(initialProfil ?? []);
const router = useRouter();
// Genişletilmiş sohbet (Bilal, 6 Eki 2026: "div'in smooth genişlemesi, diğer
// alanların kendini yeniden boyutlandırması"): panel YERİNDE büyür — perde,
// tam ekran ya da ayrı pencere yok. Boy burada yaylı geçişle uzar; listem
// ızgarası (listem-govde.tsx) aynı anda sohbet sütununu genişletir, liste ve
// harita daralan sütunda kendini yeniden akıtır. useChat durumu ve kaydırma
// korunur. Hareketi azalt: geçişsiz.
const [genisIc, setGenisIc] = useState(false);
const genis = genisProp ?? genisIc;
const kapRef = useRef<HTMLElement>(null);
const hareketAz = useReducedMotion();
function genisligiDegistir() {
const yeni = !genis;
if (genisProp === undefined) setGenisIc(yeni);
onGenisDegistir?.(yeni);
const el = kapRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const eski = rect.height;
let hedefYukseklik = "";
if (yeni) {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
const usteUzaklik = USTE_UZAKLIK_REM * rem;
// Panelin üstünü sticky noktasına getir; sayfa o kadar kaymıyorsa kalan
// fark panelin üstünde kalır ve boydan düşülür
const kayabilir = Math.max(
0,
document.documentElement.scrollHeight - window.innerHeight - window.scrollY,
);
const kaydir = Math.min(Math.max(0, rect.top - usteUzaklik), kayabilir);
const kalanUst = rect.top - kaydir;
const hedefPx = Math.max(
GENIS_EN_AZ_PX,
Math.round(window.innerHeight - kalanUst - ALT_BLUR_REM * rem),
);
hedefYukseklik = genisYukseklik || `${hedefPx}px`;
if (kaydir > 0) {
const basla = window.scrollY;
if (hareketAz) window.scrollTo(0, basla + kaydir);
else
void animate(basla, basla + kaydir, {
...SOHBET_GENISLEME_GECISI,
onUpdate: (v) => window.scrollTo(0, v),
});
}
}
// Boy geçişi: hedefi ölçüp eski değerden hedefe aynı yayla git; dar hâle
// dönünce satır içi değer kalkar, sınıftaki yükseklik yeniden geçerli olur
el.style.height = hedefYukseklik;
if (hareketAz) return;
const hedef = el.getBoundingClientRect().height;
el.style.height = `${eski}px`;
void animate(el, { height: hedef }, SOHBET_GENISLEME_GECISI).then(() => {
el.style.height = hedefYukseklik;
});
}
// useChat geri çağrıları ilk render'da yakalanır; güncel değerler ref'ten okunur.
const profilRef = useRef(profil);
@@ -299,6 +400,10 @@ export function SohbetClient({
},
});
const bekliyor = status === "submitted" || status === "streaming";
const gorunenMesajlar =
status === "submitted" && mesajlar.at(-1)?.role === "user"
? [...mesajlar, BEKLENEN_CEVAP]
: mesajlar;
/**
* İstek reddedildiyse (akış hiç başlamadı) iyimser eklenen soruyu geri al
@@ -457,7 +562,10 @@ export function SohbetClient({
return (
<section
ref={kapRef}
aria-label="Danışman sohbeti"
// Yükseklik: sınıftan (dar hâl); genişletilince satır içi stil
// (genisligiDegistir) sınıfı ezer, daraltılınca kaldırılır.
className={cn(
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-card",
// 18rem: sticky ofset (top-28 = 7rem) + geniş alt boşluk — eski 24rem
@@ -471,6 +579,20 @@ export function SohbetClient({
<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">
<button
type="button"
onClick={genisligiDegistir}
aria-pressed={genis}
aria-label={genis ? "Sohbeti daralt" : "Sohbeti genişlet"}
title={genis ? "Daralt" : "Genişlet"}
className="inline-flex size-7 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-[background-color,color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
>
{genis ? (
<Minimize2 className="size-3.5" aria-hidden />
) : (
<Maximize2 className="size-3.5" aria-hidden />
)}
</button>
{bildikleri?.length ? (
<TooltipProvider delayDuration={120}>
<Tooltip>
@@ -607,6 +729,28 @@ export function SohbetClient({
</div>
</Message>
) : null}
{uyarilar?.length ? (
<Message className="justify-start">
<DanismanAvatar renk="turuncu" />
<div className="flex max-w-[85%] flex-col gap-2">
<p className="text-xs font-semibold text-slate-500">
{uyarilar.length > 1
? "Danışman uyarıları"
: "Danışman uyarısı"}
</p>
{uyarilar.map((u) => (
// Zemin başlıktaki DanışmanlaKonuş işaretinin turuncusu
// (orange-500); yazı beyaz ve font-medium (§4.20).
<MessageContent
key={u}
className="bg-orange-500 font-medium text-white"
>
{u}
</MessageContent>
))}
</div>
</Message>
) : null}
{ilkYorum && mesajlar.length === 0 ? (
<Message className="justify-start">
<DanismanAvatar />
@@ -682,7 +826,7 @@ export function SohbetClient({
</div>
) : (
<>
{mesajlar.map((m, i) => {
{gorunenMesajlar.map((m, i) => {
const metin = mesajMetni(m);
if (m.role === "user") {
return (
@@ -693,48 +837,40 @@ export function SohbetClient({
</Message>
);
}
const canli = bekliyor && i === mesajlar.length - 1;
// Danışman veriye bakarken ne yaptığını söyler; sonuç
// gelince satır kalkar, cevap metni akar.
const araclar = canli ? calisanAraclar(m) : [];
const canli = bekliyor && i === gorunenMesajlar.length - 1;
// Danışman veriye bakarken ne yaptığını hap olarak söyler
// (arac-pilleri.tsx); haplar cevap bitince de kalır.
const piller = aracPilleri(m);
return (
<Message key={m.id} className="justify-start">
<Message
key={cevapAnahtari(gorunenMesajlar, i)}
className="justify-start"
>
{/* Cevap beklenirken/stream sürerken (son mesaj) avatar
nabız animasyonuna geçer. */}
<DanismanAvatar canli={canli} />
nabız atar; bir araç çalışırken kareler dolaşır. */}
<DanismanAvatar
canli={canli}
aracta={piller.some((p) => p.durum === "calisiyor")}
/>
<div className="flex w-full max-w-[85%] flex-col gap-2">
<AracPilleri piller={piller} />
{metin ? (
// Site içi adresler bağlantı hapı olur (sohbet-baglanti.tsx):
// markdown bağlantısı, backtick içi ve çıplak adres aynı hap
<MessageContent
markdown
components={SOHBET_MARKDOWN_BILESENLERI}
className="bg-slate-100 text-slate-800 prose-sm"
>
{metin}
{baglantilariIsaretle(metin)}
</MessageContent>
) : (
<CevapIskeleti />
)}
{araclar.length > 0 ? (
<p
role="status"
className="px-1 text-xs font-medium text-slate-500"
>
{araclar.join(" · ")}…
</p>
) : null}
</div>
</Message>
);
})}
{/* Soru gitti, cevabın ilk parçası henüz gelmedi */}
{status === "submitted" &&
mesajlar[mesajlar.length - 1]?.role === "user" ? (
<Message className="justify-start">
<DanismanAvatar canli />
<div className="w-full max-w-[85%]">
<CevapIskeleti />
</div>
</Message>
) : null}
</>
)}
<ChatContainerScrollAnchor />

View File

@@ -139,4 +139,26 @@ export const SENARYOLAR: Senaryo[] = [
})
.transport({ delayMs: 25 }),
},
{
ad: "Bağlantılar",
aciklama: "Üç biçimde site içi adres: markdown bağlantısı, backtick içinde, çıplak; dış bağlantı.",
kredi: 12,
transport: () =>
createChat()
.user("vakıf bursu kesilir mi, hangi yazıya bakayım?")
.sleep(500)
.assistant(({ writer }) => {
writer
.tool("rehber_ara", { input: { konu: "vakıf bursu kesilir mi" } })
.sleep(900)
.output({ sonuclar: [{ baslik: "Vakıf Üniversitesi Bursu Kesilir mi?", sayfa: "/rehber/vakif-universitesi-bursu-kesilir-mi" }] });
writer.text(
"Kuralı anlatan yazı: [Vakıf Üniversitesi Bursu Kesilir mi?](/rehber/vakif-universitesi-bursu-kesilir-mi). " +
"Üniversite sayfası `/universite/koc-universitesi` ve bölüm sayfası /bolum/hukuk da yardımcı olur; " +
"resmî kaynak için [ÖSYM](https://www.osym.gov.tr).\n\n" +
"Not: taban sıraları her yıl değişir.",
);
})
.transport({ delayMs: 20 }),
},
];

View File

@@ -0,0 +1,36 @@
// Listenin kendisinden (koddan) çıkan uyarılar: havuz dar geldiği için
// dağılımın ideal 5/13/6'dan sapması ve tüm tercihlerin tek şehirde olması.
// Yapay Zeka üretmez, sayılar kayıttan okunur. Danışman sohbetinin açılışında
// "Danışman uyarısı" balonu olarak gösterilir (DESIGN.md §4.20); listenin
// üstünde ayrı bir sarı kutu olarak çizilmez.
import { DILIM_ETIKET } from "@/lib/risk";
import { trBaslikDuzeni } from "@/lib/slug";
import type { RaporSonuc } from "./types/rapor";
const kucuk = (etiket: string) => etiket.toLocaleLowerCase("tr-TR");
export function listeUyarilari(rapor: RaporSonuc): string[] {
const uyarilar: string[] = [];
const kapsam = rapor.kapsam;
if (kapsam?.uyarlandi) {
uyarilar.push(
`Tercihlerine göre ${kapsam.toplam} uygun program bulundu; listen ${kapsam.dagilim.hayal} ${kucuk(DILIM_ETIKET.hayal)} · ${kapsam.dagilim.dengeli} ${kucuk(DILIM_ETIKET.dengeli)} · ${kapsam.dagilim.garanti} ${kucuk(DILIM_ETIKET.garanti)} olarak kuruldu. Daha fazla seçenek için alan veya il filtreni genişletebilirsin.`,
);
}
const iller = new Set<string>();
for (const t of rapor.tercihler) {
const il = rapor.programlar[t.programId]?.il;
if (il) iller.add(il);
}
if (iller.size === 1) {
const [tekIl] = iller;
uyarilar.push(
`Listenin ${rapor.tercihler.length} tercihi de tek şehirde (${trBaslikDuzeni(tekIl)}). Boşta kalma riskini azaltmak için birkaç farklı şehir eklemeyi düşünebilirsin.`,
);
}
return uyarilar;
}

View File

@@ -90,6 +90,7 @@ function paramsBirlestir(
iller: secimler.iller,
universiteTipi: secimler.universiteTipi,
oncelikler: secimler.oncelikler,
ozelDurum: secimler.ozelDurum,
};
}

View File

@@ -17,6 +17,7 @@ import {
oncelikNormalize,
type TercihProfili,
} from "@/features/sihirbaz/sihirbaz-sabitler";
import { OZEL_DURUM_ETIKET } from "@/lib/ozel-durum";
import type { RaporParams } from "./types/rapor";
export type FarkAlani =
@@ -25,7 +26,8 @@ export type FarkAlani =
| "kategoriler"
| "iller"
| "universiteTipi"
| "oncelikler";
| "oncelikler"
| "ozelDurum";
export const FARK_ETIKET: Record<FarkAlani, string> = {
sira: "başarı sıralaman",
@@ -34,8 +36,17 @@ export const FARK_ETIKET: Record<FarkAlani, string> = {
iller: "şehir tercihlerin",
universiteTipi: "devlet/vakıf tercihin",
oncelikler: "önceliklerin",
ozelDurum: "özel kontenjan durumun",
};
/** Kalkan çipleri (undefined) atarak normalize eder; eski kayıt yeni çip setiyle kıyaslanır. */
function oncelikleriNormalize(
degerler: readonly string[] | undefined,
): string[] | undefined {
if (!degerler) return undefined;
return degerler.map(oncelikNormalize).filter((x): x is string => typeof x === "string");
}
// Sıra bağımsız küme kıyası: sihirbaz seçimleri tekrarsız olarak doğrulanır
// (bkz. sihirbazDogrula), aynı seçimlerin farklı sırayla kaydedilmesi
// "değişiklik" değildir.
@@ -70,9 +81,11 @@ export function tercihFarki(
) {
farklar.push("universiteTipi");
}
if (kumeFarkli(params.oncelikler?.map(oncelikNormalize), s.oncelikler)) {
if (kumeFarkli(oncelikleriNormalize(params.oncelikler), s.oncelikler)) {
farklar.push("oncelikler");
}
// Eski kayıtta alan yoksa (undefined) fark sayılmaz — bkz. dosya başı ilkesi
if (kumeFarkli(params.ozelDurum, s.ozelDurum)) farklar.push("ozelDurum");
return farklar;
}
@@ -110,8 +123,17 @@ export function farkSatirlari(
yeni: UNIVERSITE_TIPI_ETIKET[s.universiteTipi],
};
case "oncelikler":
return { ...ortak, eski: liste(params.oncelikler?.map(oncelikNormalize)),
yeni: liste(s.oncelikler), };
return {
...ortak,
eski: liste(oncelikleriNormalize(params.oncelikler)),
yeni: liste(s.oncelikler),
};
case "ozelDurum":
return {
...ortak,
eski: liste(params.ozelDurum?.map((d) => OZEL_DURUM_ETIKET[d])),
yeni: liste(s.ozelDurum.map((d) => OZEL_DURUM_ETIKET[d])),
};
}
});
}

View File

@@ -3,6 +3,7 @@
// ve bu tiplere karşı derlenir; alan ekleme/çıkarma burada yapılır.
import type { DilimKey, PuanTuruKey, UniturGrubu } from "@/types/yokatlas";
import type { OzelDurum } from "@/lib/ozel-durum";
export type RaporTercih = {
programId: string;
@@ -11,6 +12,12 @@ export type RaporTercih = {
gerekce: string;
riskNotu: string;
trendOzeti: string;
/**
* Koddan hesaplanan "öne çıkan" veriler (lib/uyum-puani.ts): adayın
* önceliklerine / özel durumuna / profiline uyan ölçülebilir yanlar, sayı +
* kaynakla. Eski kayıtlarda ve ölçütsüz listelerde yok.
*/
oneCikan?: string[];
};
/**
@@ -69,6 +76,8 @@ export interface RaporParams {
iller?: string[];
universiteTipi?: UniturGrubu | "farketmez";
oncelikler?: string[];
/** Özel kontenjan durumu (6 Eki 2026; eski kayıtlarda yok) */
ozelDurum?: OzelDurum[];
// Eski akış alanları (geriye uyum — eski kayıtlı raporlar)
il?: string;
notlar?: string;