feat: update app.db and BACKLOG.md with new decisions and adjustments
All checks were successful
Deploy / deploy (push) Successful in 7m45s
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:
173
src/features/rapor/components/arac-pilleri.tsx
Normal file
173
src/features/rapor/components/arac-pilleri.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
372
src/features/rapor/components/sohbet-baglanti.tsx
Normal file
372
src/features/rapor/components/sohbet-baglanti.tsx
Normal 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"],
|
||||
};
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
];
|
||||
|
||||
36
src/features/rapor/liste-uyarilari.ts
Normal file
36
src/features/rapor/liste-uyarilari.ts
Normal 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;
|
||||
}
|
||||
@@ -90,6 +90,7 @@ function paramsBirlestir(
|
||||
iller: secimler.iller,
|
||||
universiteTipi: secimler.universiteTipi,
|
||||
oncelikler: secimler.oncelikler,
|
||||
ozelDurum: secimler.ozelDurum,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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])),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user