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

@@ -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 />