Buton "24 satırın tamamı" iddiasından arındırılmıştı (2373a9a) ama iki
eleman yukarıdaki paragraf aynı vaadi sürdürüyordu: "24 tercihlik
listenin her satırı bu formatta kurulur". Maske girişsiz/paketsiz
kullanıcıya 3 satır açıyor (rapor-maske.ts:12 ACIK_SATIR), kalan 21'in
gerekçe/risk/trend metni maskeli geliyor.
Cümleye "ilk üçü hemen açılır" eklendi — sayı yazıldı, bulanıklaştırma
ve garanti dili yok. "3" elle yazıldığı için (ACIK_SATIR `server-only`,
bu dosya "use client") tek kaynak bağı koda yorum olarak iliştirildi.
Bulgu: 36-tasarimci-g1-ve-navbar.md B2. Metin yönü Bilal'in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
9.7 KiB
TypeScript
220 lines
9.7 KiB
TypeScript
"use client";
|
||
|
||
// Girişsiz yapay zekâ tadımlığı (PRD İP-B): /sonuc'ta, sıra kovası + (varsa)
|
||
// sihirbaz alan tercihine uyan TEK örnek tercih satırı — gerçek rapor
|
||
// satırının detay bloğuyla (RaporSatirDetayi) birebir aynı formatta.
|
||
// İçerik batch üretilmiş havuzdan gelir; bu bileşen LLM'e istek attırmaz.
|
||
// SSR "genel" satırı render eder (prop), mount sonrası sihirbaz kategorisi
|
||
// varsa /api/tadimlik'ten kategoriye özel satır çekilip yerinde değiştirilir
|
||
// (seçim sunucuda kalır). Havuzda içerik yoksa hiç render edilmez.
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
import Link from "next/link";
|
||
import { ArrowRight } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { UniLogo } from "@/components/uni-logo";
|
||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||
import { RaporSatirDetayi } from "./rapor-listesi";
|
||
import {
|
||
ProgramSiraGecmisi,
|
||
ProgramTabanTrendi,
|
||
programEtkinSira,
|
||
raporSiraSerisi,
|
||
} from "@/components/program-liste-verileri";
|
||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||
import { olay, siraKovasi } from "@/lib/analitik";
|
||
import { dilimdenRisk, riskHesapla } from "@/lib/risk";
|
||
import { turkishSlugify } from "@/lib/slug";
|
||
import { listemUretHref, PROFIL_DEGISTI_EVENT } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
|
||
import type { TadimlikSatir, TadimlikTip } from "@/lib/tadimlik-havuzu";
|
||
|
||
const GENEL = "genel";
|
||
|
||
/** Program tipi ile sihirbaz tercihi eşleşiyor mu (uniturFiltreSql semantiği). */
|
||
function tipeUyar(program: TadimlikSatir["program"], tip: TadimlikTip): boolean {
|
||
if (tip === "genel") return true;
|
||
if (tip === "devlet") return program.unitur === "DEVLET";
|
||
return program.unitur != null && program.unitur !== "DEVLET";
|
||
}
|
||
|
||
export function TadimlikSatiri({
|
||
sira,
|
||
tur,
|
||
ilk,
|
||
}: {
|
||
sira: number;
|
||
tur: string;
|
||
/** SSR'da seçilmiş "genel" satır; havuz boşsa null. */
|
||
ilk: TadimlikSatir | null;
|
||
}) {
|
||
const [satir, setSatir] = useState<TadimlikSatir | null>(ilk);
|
||
const yuklenenAnahtar = useRef(`${GENEL}|${GENEL}`);
|
||
|
||
// Sihirbaz kategorisi ve devlet/vakıf tercihi yalnızca localStorage'da
|
||
// yaşar; mount'ta ve profil değiştikçe okunur, seçime özel satır sunucudan
|
||
// çekilir. Tip tercihi varsa yanlış tipte örnek bir an bile gösterilmez.
|
||
useEffect(() => {
|
||
const guncelle = () => {
|
||
const profil = tercihProfiliOku();
|
||
const kategori = profil?.secimler.kategoriler[0];
|
||
const slug = kategori ? turkishSlugify(kategori) : GENEL;
|
||
const tipTercihi = profil?.secimler.universiteTipi;
|
||
const tip: TadimlikTip =
|
||
tipTercihi === "devlet" || tipTercihi === "vakif"
|
||
? tipTercihi
|
||
: GENEL;
|
||
const anahtar = `${slug}|${tip}`;
|
||
if (anahtar === yuklenenAnahtar.current) return;
|
||
yuklenenAnahtar.current = anahtar;
|
||
if (slug === GENEL && tip === GENEL) {
|
||
setSatir(ilk);
|
||
return;
|
||
}
|
||
// SSR "genel" satırı seçilen tipe uymuyorsa fetch bitene kadar gizle
|
||
setSatir((mevcut) =>
|
||
mevcut && !tipeUyar(mevcut.program, tip) ? null : mevcut,
|
||
);
|
||
const q = new URLSearchParams({ sira: String(sira), tur });
|
||
if (slug !== GENEL) q.set("kategori", slug);
|
||
if (tip !== GENEL) q.set("tip", tip);
|
||
fetch(`/api/tadimlik?${q}`)
|
||
.then((yanit) => (yanit.ok ? yanit.json() : null))
|
||
.then((veri: { tadimlik: TadimlikSatir | null } | null) => {
|
||
// Yarış koruması: bu arada başka seçim yüklendiyse dokunma
|
||
if (yuklenenAnahtar.current !== anahtar) return;
|
||
if (veri?.tadimlik) {
|
||
setSatir(veri.tadimlik);
|
||
} else if (tip !== GENEL) {
|
||
// Uygun tipte satır yok: yanlış tip göstermektense gizle
|
||
setSatir(null);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
guncelle();
|
||
window.addEventListener(PROFIL_DEGISTI_EVENT, guncelle);
|
||
return () => window.removeEventListener(PROFIL_DEGISTI_EVENT, guncelle);
|
||
}, [sira, tur, ilk]);
|
||
|
||
// SERİ KIRILMASI (22 Eyl 2026): bu olay bu değişiklikten önce /sonuc'un
|
||
// ~2.200 px altında, tablonun arkasında yaşıyordu; artık tadımlık ilk
|
||
// ekranda. Adet bir gecede 0'dan yukarı fırlayacak — kırık ölçüm değil,
|
||
// kazanılan payda. Öncesi ve sonrası KARŞILAŞTIRILAMAZ; anlamlı ölçüt
|
||
// bundan sonra "tadımlık CTA tıklaması / tadımlık görüntülenmesi" oranıdır.
|
||
// (Not analitik.ts'e değil buraya yazıldı: o dosya aynı gece başka bir
|
||
// dalın altında — CTO şartnamesi, çakışma kararı #1.)
|
||
const gorunumRef = useGorunumOlayi<HTMLElement>("tadimlik_goruntulendi", {
|
||
kova: siraKovasi(sira),
|
||
});
|
||
|
||
if (!satir) return null;
|
||
|
||
const p = satir.program;
|
||
const risk = riskHesapla(p.efektifSira, sira) ?? dilimdenRisk(satir.dilim);
|
||
const seri = raporSiraSerisi(p.siraGecmisi, p.ilkYil);
|
||
const taban = p.efektifSira ?? programEtkinSira(seri);
|
||
const devlet = p.unitur === "DEVLET";
|
||
// Giriş dönüşü kullanıcıyı /sonuc'a geri bırakmaz: callback doğrudan
|
||
// üretimin koştuğu /listem'e iner, liste ek tık olmadan orada başlar.
|
||
const girisUrl = `/giris?callback=${encodeURIComponent(listemUretHref("tadimlik"))}`;
|
||
|
||
return (
|
||
// first:mt-0 — /sonuc'ta bu <section> main'in ilk DOM çocuğudur (harita
|
||
// artık tablonun altında, üstte hiçbir kardeş yok; JSX yorumları düğüm
|
||
// üretmez): main'in py-12'si tek başına yeterli üst boşluğu verir.
|
||
// Tadımlığın render edilmediği durumlarda (girişli kullanıcı, boş havuz)
|
||
// bu bileşen hiç dönmediği için kural ilk gerçek bölüme geçer.
|
||
<section ref={gorunumRef} className="mt-16 min-w-0 first:mt-0">
|
||
<div className="flex flex-col items-center text-center">
|
||
<SectionEyebrow>Yapay zekâdan örnek satır</SectionEyebrow>
|
||
<h2 className="mt-4 font-heading text-2xl font-bold sm:text-3xl">
|
||
Yapay zekâ listendeki her satırı böyle anlatır
|
||
</h2>
|
||
{/* "ilk üçü" = rapor-maske.ts içindeki ACIK_SATIR (3). O dosya
|
||
`server-only`, bu dosya "use client" — sabit buraya import
|
||
edilemiyor, sayı elle yazıldı. ACIK_SATIR değişirse bu cümle de
|
||
aynı commit'te değişmeli. Cümle kasten "her satır bu formatta
|
||
kurulur"u sınırlıyor: maske girişsiz/paketsiz kullanıcıya 3 satır
|
||
açıyor, kalan 21'in gerekçe/risk/trend metni maskeli geliyor. */}
|
||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||
Sıralamana uygun bir programın gerekçesi, yerleşme riski ve taban
|
||
trendi — 24 tercihlik listenin her satırı bu formatta kurulur, ilk
|
||
üçü hemen açılır.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="mt-6 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:p-6">
|
||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
||
<UniLogo ad={p.universite} boy="sm" />
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-sm font-semibold">
|
||
{p.isim}
|
||
</span>
|
||
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
|
||
{p.unitur ? (
|
||
<span
|
||
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
|
||
devlet
|
||
? "bg-slate-100 text-slate-600"
|
||
: "bg-violet-50 text-violet-600"
|
||
}`}
|
||
>
|
||
{devlet ? "Devlet" : "Vakıf"}
|
||
</span>
|
||
) : null}
|
||
<span className="truncate">
|
||
{p.universite}
|
||
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||
</span>
|
||
</span>
|
||
</span>
|
||
<span className="hidden text-right text-xs md:inline-flex">
|
||
<ProgramSiraGecmisi program={seri} />
|
||
</span>
|
||
<span className="inline-flex items-center gap-1 text-xs tabular-nums text-slate-500">
|
||
<ProgramTabanTrendi program={seri} />
|
||
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-4 rounded-lg bg-slate-50/60 px-4 py-4">
|
||
<RaporSatirDetayi
|
||
dilim={satir.dilim}
|
||
risk={risk}
|
||
unitur={p.unitur}
|
||
gerekce={satir.gerekce}
|
||
riskNotu={satir.riskNotu}
|
||
trendOzeti={satir.trendOzeti}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-5 flex flex-col items-start gap-3 border-t border-slate-100 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||
<p className="text-sm text-slate-600">
|
||
Bu, 24 satırlık Yapay Zeka listenden yalnızca bir örnek.
|
||
<span className="block text-xs text-slate-500">
|
||
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede ·
|
||
Giriş sonrası liste hemen üretilir
|
||
</span>
|
||
</p>
|
||
<Button
|
||
asChild
|
||
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
|
||
>
|
||
<Link
|
||
href={girisUrl}
|
||
onClick={() => olay("giris_cta_tiklandi", { kaynak: "tadimlik" })}
|
||
>
|
||
{/* Buton metni listenin tamamını vaat etmez: maske girişsiz
|
||
kullanıcıya 24 satırdan yalnız ilk birkaçının analizini
|
||
açıyor (rapor-maske.ts), gerisi teslim edilmiyor. */}
|
||
Kendi listemi oluştur
|
||
<ArrowRight className="size-4" aria-hidden />
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|