Compare commits

...

5 Commits

Author SHA1 Message Date
bilalgursen
507bf53a95 feat(seo): kapanan 28 bölüm + 2 üniversite sayfası benzer sayfaya kalıcı yönlendirilir
All checks were successful
Deploy / deploy (push) Successful in 6m50s
Arşivlenen slug'lar 200 + 'sayfa yok' dönüyordu (soft 404). Eşlemenin tek kaynağı
src/lib/kapanan-sluglar.ts; alt yollar (/sayfa/N) hedefin köküne gider; zincir ya da
çift kaynak build'i durdurur. Yıllık güncelleme kılavuzuna 5b adımı eklendi.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:34:52 +03:00
bilalgursen
992a01abf1 feat(katalog): başlığın altında kompakt sıra giriş satırı (alttaki kart yerinde)
Katalog 464 oturum → 12 sıra girişi; kart tablonun altında kalıyordu. CtaSiraForm'a
varyant="kompakt" + konum prop'u; olay adları aynı, konum: ust|alt özelliği eklendi;
alan kimlikleri useId ile (aynı sayfada iki form).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:34:52 +03:00
bilalgursen
ea3310ac5b fix(liste): üretim sonrası ekran listeye kesin geçer
replaceState + router.refresh() ikilisinde Next yönlendirici kuyruğu refresh'i
hiç çalıştırmıyordu (ekran 'Listen hazırlanıyor'da kalıyor, yenileyince geliyordu).
Tek router.replace; 6 sn emniyet supabı (tam yükleme); 90 sn'de 'hâlâ hazırlanıyor'.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:34:52 +03:00
bilalgursen
c2ec6d3102 content(anasayfa): karşılaştırma tablosu rakipler hakkında genelleme yapmaz
Bazı ücretsiz robotlar artık yapay zekâ sohbeti/dengeli liste sunuyor; ✗ yerine
"Bazılarında" / "Danışmana göre". İki ayırt edici satır eklendi (resmî kayda
dayanma, yöntemin açık olması). Sorun kartı ve SSS cevabı aynı çizgiye çekildi.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:34:52 +03:00
bilalgursen
95bc87e5f7 fix(paket): girişsiz ziyaretçide boş sayfa — kart gövdesi statik kabukta, yalnız CTA dinamik
Kök neden: React Flight ~3,2 KB'ı aşan dinamik parçayı ayrı satırlara bölüyor;
PPR resume bunlara verdiği segment kimliğini kabukta geç tamamlanan sınırın
(kök loading.tsx) kimliğiyle çakıştırıyor (S:3 iki kez) → HierarchyRequestError.
Lokal prod build'de yeniden üretildi, düzeltmeden sonra 5/5 temiz.
Girişsiz CTA metni: "ilk 3 tercihini ücretsiz gör" (sayılar sabitlerden).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:34:52 +03:00
10 changed files with 807 additions and 279 deletions

View File

@@ -130,6 +130,28 @@ Neden gerekli: arşivlenmezse eski kodlu satırlar listelerde bir yıl önceki s
görünmeye devam eder; öğrenci o kodu tercih ekranında bulamaz, kodu değişen program
iki kez listelenir.
### 5b. Kapanan sayfaları yönlendir (5–15 dk)
`--kuru` raporundaki `KAPANACAK /bolum/<slug>` ve `KAPANACAK /universite/<slug>`
listelerindeki her adres (ve `--kimlik-onar` sonrası kapanan sayfa olduysa onlar da)
`src/lib/kapanan-sluglar.ts` içindeki `KAPANAN_SLUGLAR` listesine eklenir; site bu
adresleri yaşayan en yakın sayfaya **kalıcı (308)** yönlendirir (`next.config.ts` →
`redirects()`, `/sayfa/N` alt yolları dahil). Eklemezsen kapanan adres Google'da
"sayfa yok" gövdesiyle kalır.
- Hedef sırası: (1) program/kurum **ad değiştirdiyse yeni adı** — kanıt: aynı
üniversitede geçmiş yıl sıraları yeni adlı satıra birebir taşınmış, ya da Resmî
Gazete kararı; (2) aynı alanın lisans/önlisans karşılığı; (3) emin değilsen hub:
`/bolumler` ya da `/universiteler`. Uydurma eşleme yapma; `gerekce` alanına kanıtı yaz.
- Hedef slug `programs` tablosundan türemeli ve kendisi listede **kaynak** olmamalı
(zincir). Zinciri dosyadaki `kapananYonlendirmeleri()` build'de yakalar; hedefin
yaşadığını sen kontrol et: 8. adımda hedef adresi tarayıcıda aç.
- Bu yıl **yeniden açılan** bir sayfa listede kaynak olarak duruyorsa kaydını sil —
yönlendirme sayfadan önce çalışır, silmezsen yeni sayfa hiç görünmez. Kontrol:
8. adımda listedeki `kaynak` adlarını `/bolumler` ve `/universiteler` sayfasında ara;
çıkan varsa kaydını sil.
- Hedef seçimi SEO'yu etkilediği için **[BİLAL] kararıdır**; listeyi commit'ten önce göster.
## 6. İndeks + dosyayı toparla (1 dk)
```bash

View File

@@ -1,5 +1,8 @@
import type { NextConfig } from "next";
import { HTML_LIMITED_BOT_UA_RE } from "next/dist/shared/lib/router/utils/html-bots";
// Bağımlılıksız veri dosyası; config uygulama paketinden önce yüklendiği için
// göreli yolla alınır (Docker builder'da src/ build'den önce kopyalanır).
import { kapananYonlendirmeleri } from "./src/lib/kapanan-sluglar";
// Dockerfile builder katmanı BUILD_KAYNAK_KISITLI=1 verir: imaj build'i prod
// VPS'te çalışan uygulamayla aynı makinede koştuğu için bellek/paralellik kısılır.
@@ -75,6 +78,10 @@ const nextConfig: NextConfig = {
{ source: "/sohbet", destination: "/listem", permanent: false },
// Ayrı robot sayfası kaldırıldı; rehber içi linkler hero formuna düşer.
{ source: "/tercih-robotu", destination: "/#hero-form", permanent: true },
// Veri güncellemesiyle tamamen kapanan /bolum ve /universite sayfaları
// (sayfalama alt yolları dahil) yaşayan en yakın sayfaya kalıcı (308)
// gider. Eşlemenin tek kaynağı src/lib/kapanan-sluglar.ts.
...kapananYonlendirmeleri(),
];
},
};

View File

@@ -89,7 +89,7 @@ const problems = [
icon: Wallet,
title: "Danışman lüks haline geldi",
description:
"İyi bir tercih danışmanı 1.000–3.000 TL istiyor. Ücretsiz robotlar ise sadece liste filtreliyor, akıl vermiyor.",
"İyi bir tercih danışmanı 1.000–3.000 TL istiyor. Ücretsiz robotların çoğu ise yalnızca liste filtreliyor; listeyi kurup gerekçesini yazmıyor.",
},
];
@@ -124,7 +124,7 @@ const faqs = [
{
question: "Ücretsiz tercih robotlarından farkı ne?",
answer:
"Ücretsiz robotlar puan aralığına göre bölüm listeler ve gerisini sana bırakır. KolayTercih ise listenin kendisini kurar: hangi tercihi kaçıncı sıraya, neden koyduğunu açıklar, riskini söyler ve sorularını cevaplar. Yani filtre değil, danışmandır.",
"Ücretsiz robotların çoğu puan aralığına göre bölüm listeler ve gerisini sana bırakır; bazıları yapay zekâ sohbeti de sunar. KolayTercih listenin kendisini kurar: hangi tercihi kaçıncı sıraya, neden koyduğunu açıklar, riskini geçmiş taban verisinden hesaplar ve sorularını cevaplar. Yapay zekâ yalnızca sıralamanla açılan gerçek programlardan seçer; bu havuzun dışına çıkamaz.",
},
{
question: "Yerleşme garantisi veriyor musunuz?",
@@ -144,22 +144,37 @@ const comparison = [
danisman: true,
tercihai: true,
},
// Rakip hücreleri genelleme yapmaz: bazı ücretsiz robotlar artık yapay zekâ
// sohbeti/dengeli liste de sunuyor, bazı danışmanlar 7/24 yazışıyor. Emin
// olmadığımız yerde ✗ yerine metin ("Bazılarında" vb.) kullanılır.
{
feature: `Son ${VERI_YIL_SAYISI} yılın trend analizi`,
robot: false,
feature: `Son ${VERI_YIL_SAYISI} yılın taban trendi, her tercihte`,
robot: "Bazılarında",
danisman: true,
tercihai: true,
},
{
feature: "Gerekçeli, dengeli 24'lük liste",
robot: false,
robot: "Bazılarında",
danisman: true,
tercihai: true,
},
{
feature: "Her öneri resmî YÖK Atlas kaydına dayanır (uydurma bölüm yok)",
robot: "Sohbet botlarında belirsiz",
danisman: true,
tercihai: true,
},
{
feature: "Nasıl çalıştığı herkese açık yazılı",
robot: "Genelde yok",
danisman: "Danışmana göre",
tercihai: true,
},
{
feature: "7/24 soru-cevap",
robot: false,
danisman: false,
robot: "Bazılarında",
danisman: "Danışmana göre",
tercihai: true,
},
{
@@ -420,7 +435,7 @@ export default function Home() {
<TableRow className="bg-slate-50">
<TableHead className="w-[40%]">Özellik</TableHead>
<TableHead className="text-center">
Ücretsiz robotlar
Ücretsiz robotlar ve sohbet botları
</TableHead>
<TableHead className="text-center">
İnsan danışman

View File

@@ -4,10 +4,7 @@ import type { Metadata } from "next";
import { SectionEyebrow } from "@/components/pixel-decor";
import { Parallax } from "@/components/parallax";
import { SiteFooter } from "@/components/site-footer";
import {
PaketSatinAl,
PaketSatinAlSkeleton,
} from "@/features/odeme/components/paket-satinal";
import { PaketSatinAl } from "@/features/odeme/components/paket-satinal";
import { sayfaMetadata } from "@/lib/seo";
export const metadata: Metadata = sayfaMetadata({
@@ -57,9 +54,9 @@ export default function PaketPage({
</p>
</Parallax>
<Suspense fallback={<PaketSatinAlSkeleton />}>
<PaketSatinAl />
</Suspense>
{/* Kart gövdesi statik kabukta; yalnız CTA alanları içeride kendi
Suspense sınırlarıyla istek anında gelir (bkz. paket-satinal.tsx). */}
<PaketSatinAl />
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas

View File

@@ -174,6 +174,9 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
const ilkSayfa = sayfa === 1;
const aralik = siraAraligiMetni(sayfaDilim);
const buYol = ilkSayfa ? `/bolum/${slug}` : `/bolum/${slug}/sayfa/${sayfa}`;
// Üst satır ve alt kart aynı soruyu sorar; sayfalama sayfalarında da bölüm
// adı geçer (ziyaretçi Google'dan doğrudan /sayfa/N'ye iniyor).
const ctaBaslik = `Sıralaman ${bolum.ad} tabanlarının neresinde?`;
return (
<>
@@ -258,6 +261,12 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
)}
</header>
{/* Tek sayfada çıkan ziyaretçi alttaki karta inmiyor: kompakt sıra girişi
başlık bloğunun hemen altında, listenin üstünde. Alttaki kart kalır. */}
<div className="mt-6">
<CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} />
</div>
<section aria-labelledby="universite-programlari" className="mt-8">
<div className="mb-4 flex items-end justify-between gap-4">
<h2
@@ -286,7 +295,8 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
{/* Üniversite sayfasıyla aynı konum ve aynı kart: liste + sayfalama altı */}
<div className="mt-10">
<CtaSiraForm
baslik={`Sıralaman ${bolum.ad} tabanlarının neresinde?`}
konum="alt"
baslik={ctaBaslik}
aciklama={KATALOG_CTA_ACIKLAMA}
/>
</div>

View File

@@ -169,6 +169,17 @@ export function UniversiteIcerik({
? `/universite/${slug}`
: `/universite/${slug}/sayfa/${sayfa}`;
const yurtdisi = uniUlkeKonum(uni.ad, uni.il);
// Üst satır ve alt kart aynı soruyu sorar; sayfalama sayfalarında da
// üniversite adı geçer (ziyaretçi Google'dan doğrudan /sayfa/N'ye iniyor).
const ctaBaslik = `Sıralaman ${uni.ad} tabanlarının neresinde?`;
// Tek sayfada çıkan ziyaretçi alttaki karta inmiyor: kompakt sıra girişi
// özet paragrafının hemen altında, tablonun (ve ilk sayfadaki istatistik
// ızgarasının) üstünde durur. Alttaki kart yerinde kalır.
const ustSiraSatiri = (
<div className="mt-6">
<CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} />
</div>
);
return (
<>
@@ -276,6 +287,8 @@ export function UniversiteIcerik({
</em>
</p>
{ustSiraSatiri}
<div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-[1fr_1fr_1.35fr] lg:grid-rows-2">
<UniversiteKonumHaritasi universite={uni.ad} il={uni.il} />
<dl className="contents">
@@ -319,22 +332,25 @@ export function UniversiteIcerik({
</div>
</>
) : (
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
{uni.ad} taban puanları tablosunun {sayfa}. sayfası (
{sayi(tumProgramlar.length)} programın{" "}
{sayi((sayfa - 1) * SAYFA_BOYU + 1)}–
{sayi(Math.min(sayfa * SAYFA_BOYU, tumProgramlar.length))}.
satırları, fakülte sırasıyla).
{birimler ? ` Bu sayfadaki birimler: ${birimler}.` : ""} Üniversite
özeti ve istatistikler{" "}
<Link
href={`/universite/${slug}`}
className="font-medium text-primary hover:underline"
>
ilk sayfada
</Link>
.
</p>
<>
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
{uni.ad} taban puanları tablosunun {sayfa}. sayfası (
{sayi(tumProgramlar.length)} programın{" "}
{sayi((sayfa - 1) * SAYFA_BOYU + 1)}–
{sayi(Math.min(sayfa * SAYFA_BOYU, tumProgramlar.length))}.
satırları, fakülte sırasıyla).
{birimler ? ` Bu sayfadaki birimler: ${birimler}.` : ""} Üniversite
özeti ve istatistikler{" "}
<Link
href={`/universite/${slug}`}
className="font-medium text-primary hover:underline"
>
ilk sayfada
</Link>
.
</p>
{ustSiraSatiri}
</>
)}
<PagePixelDivider seed={57} className="mt-8" />
@@ -363,7 +379,8 @@ export function UniversiteIcerik({
<div className="mt-10">
<CtaSiraForm
baslik={`Sıralaman ${uni.ad} tabanlarının neresinde?`}
konum="alt"
baslik={ctaBaslik}
aciklama={KATALOG_CTA_ACIKLAMA}
/>
</div>

View File

@@ -1,3 +1,4 @@
import { Suspense } from "react";
import Image from "next/image";
import Link from "next/link";
import {
@@ -11,7 +12,8 @@ import {
ShieldCheck,
} from "lucide-react";
import { getCurrentUser } from "@/lib/session";
import { URUNLER, DENEME_KREDISI } from "@/lib/credits";
import { URUNLER, DENEME_KREDISI, RAPOR_KREDI } from "@/lib/credits";
import { ACIK_SATIR } from "@/lib/rapor-maske";
import { VERI_YIL_SAYISI } from "@/lib/veri-kapsami";
import { Button } from "@/components/ui/button";
import { Parallax } from "@/components/parallax";
@@ -39,11 +41,17 @@ const OZELLIKLER = [
},
] as const;
// Paket + top-up kartları: kullanıcının paket/kredi durumuna göre CTA'lar
// değişir, okuma burada yaşar.
export async function PaketSatinAl() {
const u = await getCurrentUser();
// Paket + top-up kartları. Kartların gövdesi (başlık, fiyat, özellikler,
// iyzico bandı) STATİK kabukta kalır; kullanıcıya bağlı olan yalnızca iki küçük
// CTA alanıdır (PaketCta, TopupCta) ve her biri kendi Suspense sınırındadır.
//
// Neden bu kadar küçük parçalar: React Flight ~3,2 KB'ı aşan bir dinamik
// parçanın devamını ayrı satırlara böler; PPR resume bunlara yeni segment
// kimliği verirken kabukta geç tamamlanan sınırın (kök loading.tsx) kimliğiyle
// çakışıyor (Next 16.2 / React 19.2) ve tarayıcıda HierarchyRequestError ile
// sayfa boş kalıyordu. Dinamik parçayı bu sınırın altında tut: kartın gövdesini
// yeniden bu async bileşenlerin içine TAŞIMA.
export function PaketSatinAl() {
return (
<Parallax
strength={6}
@@ -71,44 +79,9 @@ export async function PaketSatinAl() {
))}
</ul>
<div className="mt-8">
{u ? (
u.hasPaket ? (
<div className="flex items-center gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">
<CheckCircle2 className="size-4" aria-hidden />
Paketin aktif —{" "}
{/* ?uret=1: rapor varsa /listem raporu gösterir (bayrak yok
sayılır), yoksa bekleyen seçimlerle üretim başlar; seçim
de yoksa boş durum sihirbaza yönlendirir. */}
<Link href="/listem?uret=1" className="underline">
listeni oluştur
</Link>
</div>
) : (
<SatinAlForm
urun="paket"
label={`${tl(URUNLER.paket.amountKurus)} TL — Paketi al`}
vurgulu
/>
)
) : (
<div className="space-y-2">
<Button
asChild
className="h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link
href={`/giris?callback=${encodeURIComponent("/paket")}`}
>
Giriş yap — sonra {tl(URUNLER.paket.amountKurus)} TL&apos;ye
al
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="text-center text-xs text-slate-500">
Girişte {DENEME_KREDISI} deneme kredisi de tanımlanır.
</p>
</div>
)}
<Suspense fallback={<Skeleton className="h-12 w-full rounded-full" />}>
<PaketCta />
</Suspense>
</div>
<Image
src="/odeme/iyzico-logo-band.svg"
@@ -139,47 +112,95 @@ export async function PaketSatinAl() {
{tl(URUNLER.topup.amountKurus)} TL.
</p>
<div className="mt-4">
{/* Top-up hasPaket vermez: paketsiz biri alırsa parasını verip
yine maskeli liste görür. Satın alma yalnızca paketliye
açık; paketsize durum dürüstçe açıklanır. */}
{u?.hasPaket ? (
<SatinAlForm
urun="topup"
label={`+${URUNLER.topup.credits} kredi — ${tl(URUNLER.topup.amountKurus)} TL`}
/>
) : u ? (
<p className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs leading-5 text-slate-600">
Ek kredi, kilitli analizleri açmaz — o yüzden önce paket
gerekir. Paket zaten {URUNLER.paket.credits} kredi içerir;
bitince buradan yükleyebilirsin.
</p>
) : (
<Button
asChild
variant="outline"
className="h-11 w-full cursor-pointer"
>
<Link
href={`/giris?callback=${encodeURIComponent("/paket")}`}
>
Önce giriş yap
</Link>
</Button>
)}
<Suspense fallback={<Skeleton className="h-11 w-full rounded-full" />}>
<TopupCta />
</Suspense>
</div>
</section>
</Parallax>
);
}
export function PaketSatinAlSkeleton() {
/** Ana paket kartının kullanıcıya bağlı CTA'sı — bilerek küçük (bkz. üstteki not). */
async function PaketCta() {
const u = await getCurrentUser();
if (!u) {
return (
<div className="space-y-2">
<Button
asChild
className="h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={`/giris?callback=${encodeURIComponent("/paket")}`}>
Giriş yap — ilk {ACIK_SATIR} tercihini ücretsiz gör
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="text-center text-xs text-slate-500">
Girişte {DENEME_KREDISI} deneme kredisi tanımlanır; liste{" "}
{RAPOR_KREDI} kredi, ilk {ACIK_SATIR} satır açık. Paketi sonra
alırsın.
</p>
</div>
);
}
if (u.hasPaket) {
return (
<div className="flex items-center gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">
<CheckCircle2 className="size-4" aria-hidden />
Paketin aktif —{" "}
{/* ?uret=1: rapor varsa /listem raporu gösterir (bayrak yok
sayılır), yoksa bekleyen seçimlerle üretim başlar; seçim
de yoksa boş durum sihirbaza yönlendirir. */}
<Link href="/listem?uret=1" className="underline">
listeni oluştur
</Link>
</div>
);
}
return (
<div
aria-live="polite"
className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]"
>
<Skeleton className="h-80 w-full rounded-2xl" />
<Skeleton className="h-80 w-full rounded-2xl sm:w-64" />
</div>
<SatinAlForm
urun="paket"
label={`${tl(URUNLER.paket.amountKurus)} TL — Paketi al`}
vurgulu
/>
);
}
/** Top-up kartının kullanıcıya bağlı CTA'sı — bilerek küçük (bkz. üstteki not). */
async function TopupCta() {
const u = await getCurrentUser();
// Top-up hasPaket vermez: paketsiz biri alırsa parasını verip yine maskeli
// liste görür. Satın alma yalnızca paketliye açık; paketsize durum dürüstçe
// açıklanır.
if (u?.hasPaket) {
return (
<SatinAlForm
urun="topup"
label={`+${URUNLER.topup.credits} kredi — ${tl(URUNLER.topup.amountKurus)} TL`}
/>
);
}
if (u) {
return (
<p className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs leading-5 text-slate-600">
Ek kredi, kilitli analizleri açmaz — o yüzden önce paket gerekir.
Paket zaten {URUNLER.paket.credits} kredi içerir; bitince buradan
yükleyebilirsin.
</p>
);
}
return (
<Button asChild variant="outline" className="h-11 w-full cursor-pointer">
<Link href={`/giris?callback=${encodeURIComponent("/paket")}`}>
Önce giriş yap
</Link>
</Button>
);
}

View File

@@ -10,6 +10,17 @@
// (bkz. tercih-farki.ts). Tekrar üretim riski yok: rapor varken bu bileşen
// hiç render edilmez (bkz. listem-icerik.tsx), yani ?uret=1 ile dönmek ya da
// sayfayı yenilemek ikinci bir harcama başlatmaz.
//
// Başarıdan sonra listeye geçiş (21 Eyl 2026 "hazırlanıyor'da kalıyor" hatası):
// eskiden `history.replaceState` + `router.refresh()` art arda çağrılıyordu.
// Server action'ın `await`i, Next'in aksiyon kuyruğu o action'ı henüz
// "bekleyen" sayarken devam ediyor; replaceState'in ürettiği RESTORE öne
// geçip action'ı "discarded" işaretliyor ama kuyruğun `last` işaretçisini
// güncellemiyor, ardından gelen REFRESH o atılmış düğümün arkasına bağlanıp
// HİÇ çalışmıyordu (bkz. next/dist/client/components/app-router-instance.js,
// dispatchAction). Sonuç: URL /listem oluyor, ekran iskelette kalıyordu.
// Kural: action cevabının hemen ardından yönlendiriciye TEK aksiyon gönder
// (router.replace); ikinci aksiyon (refresh) ancak o gezinme bitince.
import { useEffect, useRef, useState, type ReactNode } from "react";
import Link from "next/link";
@@ -17,7 +28,7 @@ import { useRouter } from "next/navigation";
import { ArrowRight, Lock, RefreshCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { listeOlustur } from "@/features/rapor/rapor-actions";
import { listeOlustur, type ListeSonuc } from "@/features/rapor/rapor-actions";
import { olay, siraKovasi } from "@/lib/analitik";
import { sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
@@ -25,8 +36,20 @@ import { Message } from "@/components/ui/message";
import { DanismanAvatar, DanismanBaslik } from "./sohbet-client";
import { useUretimMesaji } from "./uretim-mesaji";
// Başarıdan sonra yönlendiricinin listeye geçmesi için tanınan süre; dolduğunda
// bileşen hâlâ ekrandaysa tam sayfa yüklemeyle geçilir (emniyet supabı).
const GECIS_SINIRI_MS = 6000;
// Tek Yapay Zeka çağrısının sunucu sınırı 60 sn (bkz. lib/ai/cagri.ts), tipik
// üretim ~20 sn. Bu eşikten sonra kullanıcıya "hâlâ hazırlanıyor" denir; istek
// kesilmez, cevap gelirse akış normal devam eder.
const UZUN_SURE_MS = 90_000;
type Durum =
| { ad: "uretiliyor" }
// İstek hâlâ yolda ama beklenenden uzun sürdü: iskelet + bilgi notu
| { ad: "uzun-surdu" }
// Liste kuruldu; yönlendiricinin /listem'e geçmesi bekleniyor
| { ad: "geciliyor" }
| { ad: "kredi-yetersiz" }
| { ad: "hata"; mesaj: string }
| { ad: "profil-yok" };
@@ -57,45 +80,85 @@ export function ListeUretici({
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
setDurum({ ad: "uretiliyor" });
// try yalnızca isteği sarar: cevap geldikten SONRAKİ bir istisna "hata"
// ekranına düşerse "Tekrar dene" taze requestId ile ikinci kez kredi
// harcatırdı. Cevap kaybolursa requestId korunur → retry idempotent.
let sonuc: ListeSonuc;
try {
const sonuc = await listeOlustur({
sonuc = await listeOlustur({
sira: profil.sira,
tur: profil.tur,
secimler: profil.secimler,
requestId: requestIdRef.current,
});
if (sonuc.ok) {
requestIdRef.current = null;
olay("liste_olusturuldu", {
tur: profil.tur,
sira_kovasi: siraKovasi(profil.sira),
});
// ?uret=1 URL'de kalmasın: geri dönüşte anlamsız bayrak taşınmasın.
// refresh sunucudaki raporu (ve header'daki kredi pill'ini) ekrana
// getirir.
window.history.replaceState(null, "", "/listem");
router.refresh();
return;
}
requestIdRef.current = null;
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
olay("kredi_bitti");
setDurum({ ad: "kredi-yetersiz" });
} else if (sonuc.code === "AUTH") {
router.push(`/giris?callback=${encodeURIComponent("/listem?uret=1")}`);
} else {
olay("liste_uretim_hata", { kod: sonuc.code });
setDurum({ ad: "hata", mesaj: sonuc.error });
}
} catch {
olay("liste_uretim_hata", { kod: "ISTISNA" });
setDurum({
ad: "hata",
mesaj: "Beklenmeyen bir hata oluştu, tekrar dener misin?",
});
return;
}
requestIdRef.current = null;
if (sonuc.ok) {
olay("liste_olusturuldu", {
tur: profil.tur,
sira_kovasi: siraKovasi(profil.sira),
});
setDurum({ ad: "geciliyor" });
// Next'in kendi gezinmesi: URL (?uret=1 düşer) ve sayfa ağacı birlikte
// güncellenir. Burada ikinci bir yönlendirici aksiyonu (refresh) YOK —
// neden: dosya başındaki not. Gezinme başlatılamazsa tam yükleme.
try {
router.replace("/listem");
} catch {
window.location.replace("/listem");
}
return;
}
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
olay("kredi_bitti");
setDurum({ ad: "kredi-yetersiz" });
} else if (sonuc.code === "AUTH") {
router.push(`/giris?callback=${encodeURIComponent("/listem?uret=1")}`);
} else {
olay("liste_uretim_hata", { kod: sonuc.code });
setDurum({ ad: "hata", mesaj: sonuc.error });
}
}
// Emniyet supabı: liste kuruldu ama yönlendirici ekranı listeye çevirmediyse
// (bileşen hâlâ ekranda) tam sayfa yüklemeyle geç. replace: geri tuşu
// ?uret=1'e dönüp üretimi yeniden başlatmasın. Ayrı olay adı OlayAdi
// birliğini (lib/analitik.ts) genişletmeyi gerektirdiğinden supap,
// liste_uretim_hata'nın `kod: "GECIS_ZORLANDI"` değeriyle ayırt edilir.
useEffect(() => {
if (durum.ad !== "geciliyor") return;
const zamanlayici = setTimeout(() => {
olay("liste_uretim_hata", { kod: "GECIS_ZORLANDI" });
window.location.replace("/listem");
}, GECIS_SINIRI_MS);
return () => {
clearTimeout(zamanlayici);
// "geciliyor" son durumdur; bu temizlik yalnızca bileşen ekrandan
// kalkarken, yani gezinme işlendikten sonra koşar — kuyruk boşken
// gönderilen refresh güvenle çalışır ve layout'taki kredi pill'ini
// (gezinme layout verisini yenilemez) günceller.
router.refresh();
};
}, [durum.ad, router]);
// İstek çok uzarsa kullanıcıyı bilgilendir. İstek iptal edilmez: cevap
// sonradan gelirse yukarıdaki akış durumu zaten ezer.
useEffect(() => {
if (durum.ad !== "uretiliyor") return;
const zamanlayici = setTimeout(() => {
setDurum((d) => (d.ad === "uretiliyor" ? { ad: "uzun-surdu" } : d));
}, UZUN_SURE_MS);
return () => clearTimeout(zamanlayici);
}, [durum.ad]);
useEffect(() => {
if (basladi.current) return;
basladi.current = true;
@@ -170,10 +233,10 @@ export function ListeUretici({
);
}
return <UretimGovdesi />;
return <UretimGovdesi uzunSurdu={durum.ad === "uzun-surdu"} />;
}
function UretimGovdesi() {
function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) {
const mesaj = useUretimMesaji();
return (
@@ -182,9 +245,32 @@ function UretimGovdesi() {
<h1 className="font-heading text-xl font-bold sm:text-2xl">
Listen hazırlanıyor
</h1>
<p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p>
{uzunSurdu ? (
<>
<p className="max-w-md text-sm text-slate-600">
Bu sefer beklenenden uzun sürdü, listen hâlâ hazırlanıyor. Bu
sayfada bekleyebilir ya da hazır olup olmadığına bakabilirsin;
bakmak kredi harcamaz.
</p>
{/* ?uret=1'siz /listem üretimi asla başlatmaz: rapor hazırsa
liste, değilse boş durum gelir — kredi ikinci kez yanmaz.
Tam yükleme: yönlendirici kuyruğu bekleyen action'ın arkasında
tıkalı olduğundan router.refresh burada işe yaramaz. replace:
geri tuşu ?uret=1'e dönüp üretimi yeniden başlatmasın. */}
<Button
variant="outline"
onClick={() => window.location.replace("/listem")}
className="mt-2 cursor-pointer"
>
Listemi kontrol et
<ArrowRight className="size-4" aria-hidden />
</Button>
</>
) : (
<p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p>
)}
</header>
{/* Nihai sayfanın (listem-govde) grid'iyle aynı düzen: liste solda,

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useId, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
@@ -28,6 +28,7 @@ import {
} from "./sihirbaz-adimlar-lazy";
import { authClient } from "@/lib/auth-client";
import { olay, siraKovasi } from "@/lib/analitik";
import { cn } from "@/lib/utils";
import { onbellekliJsonGetir } from "@/lib/istek-cache";
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
import type { SihirbazFacetleri } from "@/types/yokatlas";
@@ -65,16 +66,34 @@ const KANITLAR = [
* profilsiz karttaki paragrafı değiştirir — katalog sayfaları kartı üstündeki
* tabloya bağlayan köprü cümlesini buradan verir. Başlık ve açıklamalar
* yerleşme vaadi içermez: ürün sıralamaya yakın programları listeler.
*
* `varyant="kompakt"`: katalog sayfalarında başlığın hemen altına, tablonun
* ÜSTÜNE konan tek satırlık sürüm. Sıralama alanı çekmecede değil doğrudan
* sayfadadır (tek sayfada çıkan ziyaretçi alttaki karta hiç inmiyor); gönderince
* aynı çekmece sihirbaz adımıyla açılır. Aynı sayfada alttaki kartla birlikte
* durduğu için alan kimlikleri `useId` ile üretilir, analitik olayları da
* `konum` ("ust" | "alt") özelliğiyle ayrışır — olay adları değişmez.
*/
export function CtaSiraForm({
baslik,
aciklama,
varyant = "kart",
konum = "alt",
}: {
baslik?: string;
aciklama?: string;
varyant?: "kart" | "kompakt";
/** Yalnız ölçüm içindir: formun sayfadaki yeri (tablonun üstü / altı). */
konum?: "ust" | "alt";
}) {
const profil = useTercihProfili();
const router = useRouter();
const kompakt = varyant === "kompakt";
// Aynı sayfada iki form (üst satır + alt kart) olabildiği için sabit id yok.
const kimlik = useId();
const alanId = `${kimlik}-siralama`;
const hataId = `${kimlik}-siralama-hata`;
const baslikId = `${kimlik}-baslik`;
const [acik, setAcik] = useState(false);
const [adim, setAdim] = useState<"sira" | "sihirbaz">("sira");
@@ -87,8 +106,14 @@ export function CtaSiraForm({
const [girisli, setGirisli] = useState(false);
// Huni girişi ölçümü: ref yalnızca profilsiz daldaki karta bağlanır; kayıtlı
// profilli ziyaretçi formu hiç görmediğinden sayılmaz. Sayfa ayrımı için
// özellik gerekmez — Rybbit olayın pathname'ini zaten yazar.
const formRef = useGorunumOlayi<HTMLElement>("cta_form_goruntulendi");
// özellik gerekmez — Rybbit olayın pathname'ini zaten yazar. `konum` hem
// özellik hem tekilleştirme ekidir: üstteki satır olayı atınca alttaki kartın
// gösterimi yutulmasın (oturum başına konum başına en fazla 1).
const formRef = useGorunumOlayi<HTMLElement>(
"cta_form_goruntulendi",
{ konum },
konum,
);
// Yazarken binlik ayracıyla biçimle (85000 → 85.000)
function siralamaDegisti(ham: string) {
@@ -118,13 +143,17 @@ export function CtaSiraForm({
setSira(value);
setFacetler(veri.facetler);
setAdim("sihirbaz");
// Kompakt satırda sıralama sayfada girilir; çekmece ancak şimdi,
// doğrudan sihirbaz adımıyla açılır (kartta zaten açıktır).
setAcik(true);
olay("sira_girildi", {
kaynak: "cta",
konum,
tur,
sira_kovasi: siraKovasi(value),
});
} catch {
olay("sira_form_hata", { kaynak: "cta" });
olay("sira_form_hata", { kaynak: "cta", konum });
setHata("Bir şeyler ters gitti, tekrar dener misin?");
} finally {
setYukleniyor(false);
@@ -146,13 +175,219 @@ export function CtaSiraForm({
function acikDegisti(v: boolean) {
setAcik(v);
if (!v) {
setHata(null);
// Kompakt satırda çekmecenin sıra adımı yoktur; her gönderim adımı ve
// facet'leri zaten baştan yazar (kapanış animasyonunda içerik boşalmasın).
if (kompakt) return;
// Yeniden açılışta sıfırdan başla; yazılan sıralama kaybolmasın.
setAdim("sira");
setFacetler(null);
setHata(null);
}
}
// Sıra girişi: kartta çekmecenin ilk adımı, kompakt satırda sayfanın kendisi.
// Kompakt sürüm masaüstünde (lg) tek satırdır: çipler · alan · buton.
const siraFormu = (
<form
onSubmit={siraGonder}
className={
kompakt
? "flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-center"
: "flex flex-col gap-3"
}
>
<div
role="radiogroup"
aria-label="Puan türü"
className={cn("flex flex-wrap gap-1.5", kompakt && "lg:shrink-0")}
>
{PUAN_TURLERI_SECENEK.map((t) => (
<button
key={t.deger}
type="button"
role="radio"
aria-checked={tur === t.deger}
onClick={() => setTur(t.deger)}
className={
tur === t.deger
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200"
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
}
>
{t.etiket}
</button>
))}
</div>
<label htmlFor={alanId} className="sr-only">
YKS başarı sıralaması
</label>
<div
className={cn(
"flex flex-col gap-3 sm:flex-row",
kompakt && "lg:min-w-0 lg:flex-1",
)}
>
<div className="relative sm:flex-1">
<Trophy
className="pointer-events-none absolute left-4 top-1/2 size-4 -translate-y-1/2 text-slate-400"
aria-hidden
/>
<Input
id={alanId}
inputMode="numeric"
autoComplete="off"
placeholder="YKS başarı sıralaman (ör. 85.000)"
value={siralama}
onChange={(e) => siralamaDegisti(e.target.value)}
onFocus={preloadSihirbazAdimlar}
aria-invalid={hata ? true : undefined}
aria-describedby={hata ? hataId : undefined}
className="h-12 bg-background pl-11 text-base"
/>
</div>
<Button
type="submit"
disabled={yukleniyor}
className="h-12 cursor-pointer bg-orange-500 px-6 text-white transition-colors duration-200 hover:bg-orange-600"
>
{yukleniyor ? "Hazırlanıyor…" : "Programları gör"}
<ArrowRight className="size-4" aria-hidden />
</Button>
</div>
{hata ? (
<p
id={hataId}
role="alert"
className={cn(
"text-sm font-medium text-red-600",
kompakt && "lg:basis-full",
)}
>
{hata}
</p>
) : null}
</form>
);
// Çekmece iki varyantta da aynıdır; kompakt satırda sıra adımı sayfada
// olduğundan çekmece yalnız sihirbazı taşır.
const cekmece = (
<Drawer open={acik} onOpenChange={acikDegisti} handleOnly>
<DrawerContent
className="max-h-[92dvh] bg-white sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
>
{/* Görünür başlıklar adıma göre değişir; erişilebilir ad sabit kalır. */}
<DrawerTitle className="sr-only">
Sıralamana yakın programlar hangileri?
</DrawerTitle>
<DrawerDescription className="sr-only">
YKS başarı sıralamanı gir; sıralamana uygun programları bu sayfadan
ayrılmadan gör.
</DrawerDescription>
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</DrawerClose>
<div
className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6"
>
{!kompakt && adim === "sira" ? (
<div className="flex flex-col gap-5">
<div>
<SectionEyebrow>Saniyede gör</SectionEyebrow>
<h2 className="mt-3 font-heading text-2xl font-bold text-slate-900">
Sıralamana yakın programlar hangileri?
</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-600">
Puan türünü seç, başarı sıralamanı yaz; hayal, dengeli ve
güvenli dilimlerindeki programları bu sayfadan ayrılmadan
gör. Ücretsiz, üyelik gerekmez.
</p>
</div>
{siraFormu}
<ul
aria-label="Neler alacaksın"
className="flex flex-wrap gap-x-4 gap-y-2 border-t border-slate-100 pt-4"
>
{KANITLAR.map(({ ikon: Ikon, etiket }) => (
<li
key={etiket}
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-600"
>
<Ikon className="size-3.5 text-primary" aria-hidden />
{etiket}
</li>
))}
</ul>
</div>
) : facetler && sira != null ? (
<SihirbazAdimlarLazy
key={`${sira}-${tur}`}
sira={sira}
tur={tur}
facetler={facetler}
sonButonEtiketi={
girisli ? "Yapay Zeka listemi kur (3 kredi)" : "Ücretsiz tablonu gör"
}
onTamamla={secimleriTamamla}
/>
) : null}
</div>
</DrawerContent>
</Drawer>
);
if (kompakt) {
// CLS: kayıtlı profil hydration SONRASI bulunur. Satır tablonun üstünde
// olduğundan yüksekliği o anda değişmemeli — form yerinde (görünmez ve
// inert) kalır, profilli kısayol aynı ızgara hücresine bindirilir; hücrenin
// yüksekliğini her iki durumda da form belirler.
return (
<section
aria-labelledby={baslikId}
className="rounded-2xl border border-slate-200 bg-white p-4 sm:p-5"
>
<p className="text-sm leading-6 text-slate-600">
<strong
id={baslikId}
className="font-heading text-base font-bold text-slate-900"
>
{baslik ?? "Sıralaman bu tabanların neresinde?"}
</strong>{" "}
Ücretsiz, üyelik gerekmez.
</p>
<div className="mt-3 grid">
<div
ref={profil ? undefined : formRef}
inert={profil ? true : undefined}
className={cn("col-start-1 row-start-1", profil && "invisible")}
>
{siraFormu}
</div>
{profil ? (
<div className="col-start-1 row-start-1 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm leading-6 text-slate-600">
Sıralaman kayıtlı ({profil.sira.toLocaleString("tr-TR")}. sıra).
</p>
<Link
href={sonucHref()}
className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-orange-500 px-6 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
>
Sıralamana uygun programları gör
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
) : null}
</div>
{cekmece}
</section>
);
}
if (profil) {
return (
<section className="rounded-2xl border border-primary/20 bg-primary/5 p-6 sm:p-8">
@@ -198,138 +433,7 @@ export function CtaSiraForm({
<ArrowRight className="size-4" aria-hidden />
</Button>
<Drawer open={acik} onOpenChange={acikDegisti} handleOnly>
<DrawerContent
className="max-h-[92dvh] bg-white sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
>
{/* Görünür başlıklar adıma göre değişir; erişilebilir ad sabit kalır. */}
<DrawerTitle className="sr-only">
Sıralamana yakın programlar hangileri?
</DrawerTitle>
<DrawerDescription className="sr-only">
YKS başarı sıralamanı gir; sıralamana uygun programları bu sayfadan
ayrılmadan gör.
</DrawerDescription>
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</DrawerClose>
<div
className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6"
>
{adim === "sira" ? (
<div className="flex flex-col gap-5">
<div>
<SectionEyebrow>Saniyede gör</SectionEyebrow>
<h2 className="mt-3 font-heading text-2xl font-bold text-slate-900">
Sıralamana yakın programlar hangileri?
</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-600">
Puan türünü seç, başarı sıralamanı yaz; hayal, dengeli ve
güvenli dilimlerindeki programları bu sayfadan ayrılmadan
gör. Ücretsiz, üyelik gerekmez.
</p>
</div>
<form onSubmit={siraGonder} className="flex flex-col gap-3">
<div
role="radiogroup"
aria-label="Puan türü"
className="flex flex-wrap gap-1.5"
>
{PUAN_TURLERI_SECENEK.map((t) => (
<button
key={t.deger}
type="button"
role="radio"
aria-checked={tur === t.deger}
onClick={() => setTur(t.deger)}
className={
tur === t.deger
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200"
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
}
>
{t.etiket}
</button>
))}
</div>
<label htmlFor="cta-siralama" className="sr-only">
YKS başarı sıralaması
</label>
<div className="flex flex-col gap-3 sm:flex-row">
<div className="relative sm:flex-1">
<Trophy
className="pointer-events-none absolute left-4 top-1/2 size-4 -translate-y-1/2 text-slate-400"
aria-hidden
/>
<Input
id="cta-siralama"
inputMode="numeric"
autoComplete="off"
placeholder="YKS başarı sıralaman (ör. 85.000)"
value={siralama}
onChange={(e) => siralamaDegisti(e.target.value)}
onFocus={preloadSihirbazAdimlar}
aria-invalid={hata ? true : undefined}
aria-describedby={
hata ? "cta-siralama-hata" : undefined
}
className="h-12 bg-background pl-11 text-base"
/>
</div>
<Button
type="submit"
disabled={yukleniyor}
className="h-12 cursor-pointer bg-orange-500 px-6 text-white transition-colors duration-200 hover:bg-orange-600"
>
{yukleniyor ? "Hazırlanıyor…" : "Programları gör"}
<ArrowRight className="size-4" aria-hidden />
</Button>
</div>
{hata ? (
<p
id="cta-siralama-hata"
role="alert"
className="text-sm font-medium text-red-600"
>
{hata}
</p>
) : null}
</form>
<ul
aria-label="Neler alacaksın"
className="flex flex-wrap gap-x-4 gap-y-2 border-t border-slate-100 pt-4"
>
{KANITLAR.map(({ ikon: Ikon, etiket }) => (
<li
key={etiket}
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-600"
>
<Ikon className="size-3.5 text-primary" aria-hidden />
{etiket}
</li>
))}
</ul>
</div>
) : facetler && sira != null ? (
<SihirbazAdimlarLazy
key={`${sira}-${tur}`}
sira={sira}
tur={tur}
facetler={facetler}
sonButonEtiketi={
girisli ? "Yapay Zeka listemi kur (3 kredi)" : "Ücretsiz tablonu gör"
}
onTamamla={secimleriTamamla}
/>
) : null}
</div>
</DrawerContent>
</Drawer>
{cekmece}
</section>
);
}

249
src/lib/kapanan-sluglar.ts Normal file
View File

@@ -0,0 +1,249 @@
/**
* Kapanan katalog sayfaları → yaşayan en yakın sayfa (kalıcı yönlendirme).
*
* Neden var: yıllık veri güncellemesinde son kılavuzda olmayan program satırları
* `programs_arsiv`'e taşınır; bir bölüm adının ya da üniversitenin bütün
* satırları giderse /bolum/<slug> ya da /universite/<slug> sayfası tamamen
* kapanır. Bu URL'ler arama sonuçlarında ve dış linklerde yaşamaya devam eder;
* "sayfa yok" göstermek yerine adı/alanı en yakın yaşayan sayfaya 308 verilir.
* Tek tüketici `next.config.ts` içindeki `redirects()`'tir.
*
* Yıllık güncellemede (docs/veri/yillik-guncelleme.md, 5. adım):
* `pnpm eski-arsivle --kuru` çıktısındaki "tamamen kapanan" slug'lar buraya
* eklenir. Hedef seçimi sırasıyla: (1) program/kurum ad değiştirdiyse yeni adı
* (kanıt: aynı üniversitede geçmiş yıl sıraları birebir taşınmış satır, ya da
* resmî karar), (2) aynı alanın lisans/önlisans karşılığı, (3) emin
* olunamıyorsa hub (`/bolumler`, `/universiteler`). Uydurma eşleme yapılmaz.
*
* Kurallar:
* - `hedef` yaşayan bir sayfa olmalı (slug `programs` tablosundan türemeli).
* - `hedef` başka bir kaydın `kaynak`'ı olamaz (zincir/döngü) —
* `kapananYonlendirmeleri()` bunu build'de sınar ve ihlâlde durur.
* - Kapanan bir sayfa ileride yeniden açılırsa (program kılavuza dönerse)
* kaydı SİL; yoksa yönlendirme dosya sisteminden önce çalıştığı için yeni
* sayfa hiç görünmez.
*
* Bu dosya `next.config.ts`'ten, uygulama paketinin dışında yüklenir: bağımlılıksız
* kalmalı — `server-only`, DB ya da başka bir uygulama modülü import ETME.
*/
export type KapananSlug = {
/** Kapanan sayfanın yolu, sonda eğik çizgi yok: `/bolum/<slug>` | `/universite/<slug>` */
kaynak: `/bolum/${string}` | `/universite/${string}`;
/** Yaşayan hedef sayfa ya da hub */
hedef: `/bolum/${string}` | `/universite/${string}` | "/bolumler" | "/universiteler";
/** Hedefin neden seçildiği (kanıt türüyle) */
gerekce: string;
};
/** 2026 veri yayını (21 Eylül 2026): 28 bölüm + 2 üniversite sayfası kapandı. */
export const KAPANAN_SLUGLAR: readonly KapananSlug[] = [
// ---- Bölümler: ad değişikliği veriyle kanıtlı (geçmiş sıralar yeni ada taşınmış) ----
{
kaynak: "/bolum/radyo-ve-televizyon-programciligi",
hedef: "/bolum/medya-ve-iletisim",
gerekce:
"Önlisans programı 2024'ten sonra kılavuzda yok; İzmir Ekonomi Ü. MYO'da geçmiş sıralar Medya ve İletişim'e taşınmış (ardıl ad), hedefte 21 önlisans programı var",
},
{
kaynak: "/bolum/iletisim-sanatlari",
hedef: "/bolum/halkla-iliskiler-ve-reklamcilik",
gerekce:
"Tek kurum (İstanbul Kültür Ü. Sanat ve Tasarım Fak.); 3 satırın 2023 sıraları aynı fakültedeki Halkla İlişkiler ve Reklamcılık satırlarına taşınmış",
},
{
kaynak: "/bolum/cevre-temizligi-ve-denetimi",
hedef: "/bolum/cevresel-olcum-ve-izleme-sistemleri-teknikerligi",
gerekce:
"Tek kurum (Kocaeli Ü. Hereke Asım Kocabıyık MYO); geçmiş sırası aynı MYO'daki yeni adlı programa taşınmış",
},
{
kaynak: "/bolum/atik-yonetimi",
hedef: "/bolum/su-ve-atik-yonetimi-teknikerligi",
gerekce:
"Tek kurum (Karamanoğlu Mehmetbey Ü.); geçmiş sırası aynı üniversitedeki Su ve Atık Yönetimi Teknikerliği'ne taşınmış",
},
{
kaynak: "/bolum/emlak-ve-emlak-yonetimi",
hedef: "/bolum/gayrimenkul-gelistirme-ve-yonetimi",
gerekce:
"Tek kurum (Ankara Hacı Bayram Veli Ü. Tapu Kadastro YO); geçmiş sırası aynı okuldaki Gayrimenkul Geliştirme ve Yönetimi'ne taşınmış",
},
{
kaynak: "/bolum/meteoroloji-muhendisligi",
hedef: "/bolum/iklim-bilimi-ve-meteoroloji-muhendisligi",
gerekce:
"Tek kurum (Samsun Ü.); program aynı fakültede İklim Bilimi ve Meteoroloji Mühendisliği adıyla sürüyor",
},
{
kaynak: "/bolum/endustri-yonetimi-muhendisligi",
hedef: "/bolum/endustri-muhendisligi",
gerekce:
"Tek kurum (Uluslararası Balkan Ü.); aynı program kimlikleri 2026 kılavuzunda Endüstri Mühendisliği adıyla",
},
{
kaynak: "/bolum/taki-tasarimi",
hedef: "/bolum/kuyumculuk-ve-mucevher-tasarimi",
gerekce:
"Lisans; Altınbaş Ü. Uygulamalı Bilimler Fak.'nde Takı Tasarımı 2024'te bitti, aynı fakültede 2025'ten itibaren Kuyumculuk ve Mücevher Tasarımı var",
},
{
kaynak: "/bolum/islam-bilimleri",
hedef: "/bolum/ilahiyat",
gerekce:
"Sayfanın tek satırı (Kırgızistan-Türkiye Manas Ü. İlahiyat Fak., kimlik 405210389) 2026 kılavuzunda İlahiyat adıyla",
},
{
kaynak: "/bolum/bati-dilleri",
hedef: "/bolum/filoloji",
gerekce:
"Tek kurum (Manas Ü. Edebiyat Fak.); 2024'te kapandı, aynı fakültede 2025'ten itibaren Filoloji (İngiliz Dili ve Edebiyatı) açıldı",
},
{
kaynak: "/bolum/dogu-dilleri",
hedef: "/bolum/filoloji",
gerekce:
"Tek kurum (Manas Ü. Edebiyat Fak.); Batı Dilleri ile aynı yeniden yapılanma, fakültenin dil programları Filoloji altında",
},
// ---- Bölümler: aynı alanın lisans karşılığı / ad yakınlığı ----
{
kaynak: "/bolum/uygulamali-ingilizce-cevirmenlik",
hedef: "/bolum/ingilizce-mutercim-ve-tercumanlik",
gerekce:
"Önlisans programı 2024'ten sonra kılavuzda yok, önlisansta çeviri programı kalmadı; aynı alanın lisans karşılığı",
},
{
kaynak: "/bolum/uygulamali-ispanyolca-cevirmenlik",
hedef: "/bolum/ispanyolca-mutercim-ve-tercumanlik",
gerekce: "Önlisans programı kapandı; aynı alanın lisans karşılığı",
},
{
kaynak: "/bolum/menkul-kiymetler-ve-sermaye-piyasasi",
hedef: "/bolum/sermaye-piyasasi",
gerekce: "Önlisans programı kapandı; aynı alanın lisans karşılığı",
},
{
kaynak: "/bolum/siyasal-bilimler",
hedef: "/bolum/siyaset-bilimi",
gerekce: "Tek kurum (Uluslararası Balkan Ü.); eş anlamlı ad",
},
{
kaynak: "/bolum/uluslararasi-ekonomi",
hedef: "/bolum/ekonomi",
gerekce: "Tek kurum (Uluslararası Balkan Ü.); adı en yakın yaşayan bölüm",
},
{
kaynak: "/bolum/girisimcilik",
hedef: "/bolum/uluslararasi-girisimcilik",
gerekce: "Tek kurum (Özyeğin Ü.); adı en yakın yaşayan bölüm",
},
{
kaynak: "/bolum/film-tasarimi-ve-yazarligi",
hedef: "/bolum/film-tasarimi-ve-yonetimi",
gerekce: "Tek kurum (Dokuz Eylül Ü. GSF); aynı alan, adı en yakın yaşayan bölüm",
},
{
kaynak: "/bolum/film-tasarimi-ve-yonetmenligi",
hedef: "/bolum/film-tasarimi-ve-yonetimi",
gerekce: "Tek kurum (Dokuz Eylül Ü. GSF); aynı alan, adı en yakın yaşayan bölüm",
},
{
kaynak: "/bolum/muhendislik-programlari",
hedef: "/bolum/muhendislik-ve-doga-bilimleri-programlari",
gerekce:
"TED Ü. bölümsüz mühendislik girişi kapandı; aynı türden (fakülteye giriş, bölüm sonradan) yaşayan tek sayfa",
},
{
kaynak: "/bolum/iktisadi-ve-idari-bilimler-programlari",
hedef: "/bolum/yonetim-bilimleri-programlari",
gerekce:
"TED Ü. bölümsüz İİBF girişi kapandı; aynı türden (fakülteye giriş, bölüm sonradan) en yakın yaşayan sayfa",
},
// ---- Bölümler: alan yakınlığı — ardıl ad kanıtı YOK, Bilal onayı bekliyor ----
{
kaynak: "/bolum/spor-yoneticiligi",
hedef: "/bolum/rekreasyon",
gerekce:
"113 satırın tamamı 2026 kılavuzundan çıktı, ardıl ad yok; spor bilimleri fakültelerinde merkezi yerleştirmeyle öğrenci alan kardeş bölüm (Rekreasyon Yönetimi turizm fakültelerinde, o yüzden seçilmedi)",
},
{
kaynak: "/bolum/spor-yonetimi",
hedef: "/bolum/rekreasyon",
gerekce:
"Önlisans programı 2024'ten sonra kılavuzda yok, ardıl ad yok; Spor Yöneticiliği ile aynı hedef",
},
{
kaynak: "/bolum/bilgi-yonetimi",
hedef: "/bolum/bilgi-ve-belge-yonetimi",
gerekce:
"Önlisans programı 2024'ten sonra kılavuzda yok, ardıl ad yok; adı ve alanı en yakın yaşayan (lisans) bölüm",
},
{
kaynak: "/bolum/ambalaj-tasarimi",
hedef: "/bolum/grafik-tasarimi",
gerekce:
"Tek kurum (Fatih Sultan Mehmet Vakıf Ü. MYO), ardıl ad yok; ambalaj tasarımı grafik tasarımın alt dalı, hedefte 119 önlisans programı var",
},
// ---- Bölümler: yakın sayfa yok → hub ----
{
kaynak: "/bolum/nufus-ve-vatandaslik",
hedef: "/bolumler",
gerekce: "Önlisans programı kapandı; ardıl ad ya da alanı yakın yaşayan bölüm yok",
},
{
kaynak: "/bolum/aile-ve-tuketici-bilimleri",
hedef: "/bolumler",
gerekce: "Tek kurum (Hacettepe Ü.); alanı yakın yaşayan bölüm yok",
},
{
kaynak: "/bolum/ev-idaresi",
hedef: "/bolumler",
gerekce: "Tek kurum (Anadolu Ü. AÖF); alanı yakın yaşayan bölüm yok",
},
// ---- Üniversiteler: kurum ad değiştirdi ----
{
kaynak: "/universite/izmir-kavram-meslek-yuksekokulu",
hedef: "/universite/izmir-konak-meslek-yuksekokulu",
gerekce:
"Ad değişikliği: 10929 sayılı Cumhurbaşkanı Kararı, Resmî Gazete 31.01.2026 / 33154; 2026 kılavuzunda yeni adla 72 program",
},
{
kaynak: "/universite/kibris-ilim-universitesi",
hedef: "/universite/kibris-aydin-universitesi",
gerekce:
"Ad değişikliği (Mart 2025); veride Kıbrıs Aydın Ü. satırları Kıbrıs İlim Ü.'nün aynı programlarını ve 2024 sıralarını birebir taşıyor",
},
];
export type KaliciYonlendirme = {
source: string;
destination: string;
permanent: true;
};
/**
* `next.config.ts` → `redirects()` için kurallar. `:yol*` sıfır ya da daha çok
* parça eşler: sayfanın kendisi, `/sayfa/N` sayfalama URL'leri ve diğer alt
* yollar hedefin KÖK sayfasına gider (hedefin sayfa sayısı farklıdır, N taşınmaz).
*/
export function kapananYonlendirmeleri(): KaliciYonlendirme[] {
const kaynaklar = new Set<string>();
for (const { kaynak } of KAPANAN_SLUGLAR) {
if (kaynaklar.has(kaynak)) {
throw new Error(`kapanan-sluglar: "${kaynak}" iki kez tanımlı`);
}
kaynaklar.add(kaynak);
}
return KAPANAN_SLUGLAR.map(({ kaynak, hedef }) => {
if (kaynaklar.has(hedef)) {
throw new Error(
`kapanan-sluglar: "${kaynak}" → "${hedef}" zincir oluşturuyor (hedef de kapanan listesinde)`,
);
}
return { source: `${kaynak}/:yol*`, destination: hedef, permanent: true };
});
}