Compare commits
5 Commits
9e9de84a7e
...
507bf53a95
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
507bf53a95 | ||
|
|
992a01abf1 | ||
|
|
ea3310ac5b | ||
|
|
c2ec6d3102 | ||
|
|
95bc87e5f7 |
@@ -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
|
görünmeye devam eder; öğrenci o kodu tercih ekranında bulamaz, kodu değişen program
|
||||||
iki kez listelenir.
|
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)
|
## 6. İndeks + dosyayı toparla (1 dk)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import { HTML_LIMITED_BOT_UA_RE } from "next/dist/shared/lib/router/utils/html-bots";
|
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
|
// 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.
|
// 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 },
|
{ source: "/sohbet", destination: "/listem", permanent: false },
|
||||||
// Ayrı robot sayfası kaldırıldı; rehber içi linkler hero formuna düşer.
|
// Ayrı robot sayfası kaldırıldı; rehber içi linkler hero formuna düşer.
|
||||||
{ source: "/tercih-robotu", destination: "/#hero-form", permanent: true },
|
{ 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(),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const problems = [
|
|||||||
icon: Wallet,
|
icon: Wallet,
|
||||||
title: "Danışman lüks haline geldi",
|
title: "Danışman lüks haline geldi",
|
||||||
description:
|
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?",
|
question: "Ücretsiz tercih robotlarından farkı ne?",
|
||||||
answer:
|
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?",
|
question: "Yerleşme garantisi veriyor musunuz?",
|
||||||
@@ -144,22 +144,37 @@ const comparison = [
|
|||||||
danisman: true,
|
danisman: true,
|
||||||
tercihai: 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`,
|
feature: `Son ${VERI_YIL_SAYISI} yılın taban trendi, her tercihte`,
|
||||||
robot: false,
|
robot: "Bazılarında",
|
||||||
danisman: true,
|
danisman: true,
|
||||||
tercihai: true,
|
tercihai: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
feature: "Gerekçeli, dengeli 24'lük liste",
|
feature: "Gerekçeli, dengeli 24'lük liste",
|
||||||
robot: false,
|
robot: "Bazılarında",
|
||||||
danisman: true,
|
danisman: true,
|
||||||
tercihai: 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",
|
feature: "7/24 soru-cevap",
|
||||||
robot: false,
|
robot: "Bazılarında",
|
||||||
danisman: false,
|
danisman: "Danışmana göre",
|
||||||
tercihai: true,
|
tercihai: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -420,7 +435,7 @@ export default function Home() {
|
|||||||
<TableRow className="bg-slate-50">
|
<TableRow className="bg-slate-50">
|
||||||
<TableHead className="w-[40%]">Özellik</TableHead>
|
<TableHead className="w-[40%]">Özellik</TableHead>
|
||||||
<TableHead className="text-center">
|
<TableHead className="text-center">
|
||||||
Ücretsiz robotlar
|
Ücretsiz robotlar ve sohbet botları
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center">
|
<TableHead className="text-center">
|
||||||
İnsan danışman
|
İnsan danışman
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import type { Metadata } from "next";
|
|||||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||||
import { Parallax } from "@/components/parallax";
|
import { Parallax } from "@/components/parallax";
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import {
|
import { PaketSatinAl } from "@/features/odeme/components/paket-satinal";
|
||||||
PaketSatinAl,
|
|
||||||
PaketSatinAlSkeleton,
|
|
||||||
} from "@/features/odeme/components/paket-satinal";
|
|
||||||
import { sayfaMetadata } from "@/lib/seo";
|
import { sayfaMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
export const metadata: Metadata = sayfaMetadata({
|
export const metadata: Metadata = sayfaMetadata({
|
||||||
@@ -57,9 +54,9 @@ export default function PaketPage({
|
|||||||
</p>
|
</p>
|
||||||
</Parallax>
|
</Parallax>
|
||||||
|
|
||||||
<Suspense fallback={<PaketSatinAlSkeleton />}>
|
{/* Kart gövdesi statik kabukta; yalnız CTA alanları içeride kendi
|
||||||
<PaketSatinAl />
|
Suspense sınırlarıyla istek anında gelir (bkz. paket-satinal.tsx). */}
|
||||||
</Suspense>
|
<PaketSatinAl />
|
||||||
|
|
||||||
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
|
<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
|
KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas
|
||||||
|
|||||||
@@ -174,6 +174,9 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
|||||||
const ilkSayfa = sayfa === 1;
|
const ilkSayfa = sayfa === 1;
|
||||||
const aralik = siraAraligiMetni(sayfaDilim);
|
const aralik = siraAraligiMetni(sayfaDilim);
|
||||||
const buYol = ilkSayfa ? `/bolum/${slug}` : `/bolum/${slug}/sayfa/${sayfa}`;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -258,6 +261,12 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</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">
|
<section aria-labelledby="universite-programlari" className="mt-8">
|
||||||
<div className="mb-4 flex items-end justify-between gap-4">
|
<div className="mb-4 flex items-end justify-between gap-4">
|
||||||
<h2
|
<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ı */}
|
{/* Üniversite sayfasıyla aynı konum ve aynı kart: liste + sayfalama altı */}
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<CtaSiraForm
|
<CtaSiraForm
|
||||||
baslik={`Sıralaman ${bolum.ad} tabanlarının neresinde?`}
|
konum="alt"
|
||||||
|
baslik={ctaBaslik}
|
||||||
aciklama={KATALOG_CTA_ACIKLAMA}
|
aciklama={KATALOG_CTA_ACIKLAMA}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -169,6 +169,17 @@ export function UniversiteIcerik({
|
|||||||
? `/universite/${slug}`
|
? `/universite/${slug}`
|
||||||
: `/universite/${slug}/sayfa/${sayfa}`;
|
: `/universite/${slug}/sayfa/${sayfa}`;
|
||||||
const yurtdisi = uniUlkeKonum(uni.ad, uni.il);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -276,6 +287,8 @@ export function UniversiteIcerik({
|
|||||||
</em>
|
</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{ustSiraSatiri}
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-[1fr_1fr_1.35fr] lg:grid-rows-2">
|
<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} />
|
<UniversiteKonumHaritasi universite={uni.ad} il={uni.il} />
|
||||||
<dl className="contents">
|
<dl className="contents">
|
||||||
@@ -319,22 +332,25 @@ export function UniversiteIcerik({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
<>
|
||||||
{uni.ad} taban puanları tablosunun {sayfa}. sayfası (
|
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
||||||
{sayi(tumProgramlar.length)} programın{" "}
|
{uni.ad} taban puanları tablosunun {sayfa}. sayfası (
|
||||||
{sayi((sayfa - 1) * SAYFA_BOYU + 1)}–
|
{sayi(tumProgramlar.length)} programın{" "}
|
||||||
{sayi(Math.min(sayfa * SAYFA_BOYU, tumProgramlar.length))}.
|
{sayi((sayfa - 1) * SAYFA_BOYU + 1)}–
|
||||||
satırları, fakülte sırasıyla).
|
{sayi(Math.min(sayfa * SAYFA_BOYU, tumProgramlar.length))}.
|
||||||
{birimler ? ` Bu sayfadaki birimler: ${birimler}.` : ""} Üniversite
|
satırları, fakülte sırasıyla).
|
||||||
özeti ve istatistikler{" "}
|
{birimler ? ` Bu sayfadaki birimler: ${birimler}.` : ""} Üniversite
|
||||||
<Link
|
özeti ve istatistikler{" "}
|
||||||
href={`/universite/${slug}`}
|
<Link
|
||||||
className="font-medium text-primary hover:underline"
|
href={`/universite/${slug}`}
|
||||||
>
|
className="font-medium text-primary hover:underline"
|
||||||
ilk sayfada
|
>
|
||||||
</Link>
|
ilk sayfada
|
||||||
.
|
</Link>
|
||||||
</p>
|
.
|
||||||
|
</p>
|
||||||
|
{ustSiraSatiri}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PagePixelDivider seed={57} className="mt-8" />
|
<PagePixelDivider seed={57} className="mt-8" />
|
||||||
@@ -363,7 +379,8 @@ export function UniversiteIcerik({
|
|||||||
|
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<CtaSiraForm
|
<CtaSiraForm
|
||||||
baslik={`Sıralaman ${uni.ad} tabanlarının neresinde?`}
|
konum="alt"
|
||||||
|
baslik={ctaBaslik}
|
||||||
aciklama={KATALOG_CTA_ACIKLAMA}
|
aciklama={KATALOG_CTA_ACIKLAMA}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +12,8 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getCurrentUser } from "@/lib/session";
|
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 { VERI_YIL_SAYISI } from "@/lib/veri-kapsami";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Parallax } from "@/components/parallax";
|
import { Parallax } from "@/components/parallax";
|
||||||
@@ -39,11 +41,17 @@ const OZELLIKLER = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Paket + top-up kartları: kullanıcının paket/kredi durumuna göre CTA'lar
|
// Paket + top-up kartları. Kartların gövdesi (başlık, fiyat, özellikler,
|
||||||
// değişir, okuma burada yaşar.
|
// iyzico bandı) STATİK kabukta kalır; kullanıcıya bağlı olan yalnızca iki küçük
|
||||||
export async function PaketSatinAl() {
|
// CTA alanıdır (PaketCta, TopupCta) ve her biri kendi Suspense sınırındadır.
|
||||||
const u = await getCurrentUser();
|
//
|
||||||
|
// 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 (
|
return (
|
||||||
<Parallax
|
<Parallax
|
||||||
strength={6}
|
strength={6}
|
||||||
@@ -71,44 +79,9 @@ export async function PaketSatinAl() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
{u ? (
|
<Suspense fallback={<Skeleton className="h-12 w-full rounded-full" />}>
|
||||||
u.hasPaket ? (
|
<PaketCta />
|
||||||
<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">
|
</Suspense>
|
||||||
<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'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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Image
|
<Image
|
||||||
src="/odeme/iyzico-logo-band.svg"
|
src="/odeme/iyzico-logo-band.svg"
|
||||||
@@ -139,47 +112,95 @@ export async function PaketSatinAl() {
|
|||||||
{tl(URUNLER.topup.amountKurus)} TL.
|
{tl(URUNLER.topup.amountKurus)} TL.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{/* Top-up hasPaket vermez: paketsiz biri alırsa parasını verip
|
<Suspense fallback={<Skeleton className="h-11 w-full rounded-full" />}>
|
||||||
yine maskeli liste görür. Satın alma yalnızca paketliye
|
<TopupCta />
|
||||||
açık; paketsize durum dürüstçe açıklanır. */}
|
</Suspense>
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Parallax>
|
</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 (
|
return (
|
||||||
<div
|
<SatinAlForm
|
||||||
aria-live="polite"
|
urun="paket"
|
||||||
className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]"
|
label={`${tl(URUNLER.paket.amountKurus)} TL — Paketi al`}
|
||||||
>
|
vurgulu
|
||||||
<Skeleton className="h-80 w-full rounded-2xl" />
|
/>
|
||||||
<Skeleton className="h-80 w-full rounded-2xl sm:w-64" />
|
);
|
||||||
</div>
|
}
|
||||||
|
|
||||||
|
/** 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
// (bkz. tercih-farki.ts). Tekrar üretim riski yok: rapor varken bu bileşen
|
// (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
|
// hiç render edilmez (bkz. listem-icerik.tsx), yani ?uret=1 ile dönmek ya da
|
||||||
// sayfayı yenilemek ikinci bir harcama başlatmaz.
|
// 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 { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -17,7 +28,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { ArrowRight, Lock, RefreshCcw } from "lucide-react";
|
import { ArrowRight, Lock, RefreshCcw } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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 { olay, siraKovasi } from "@/lib/analitik";
|
||||||
import { sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
import { sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
|
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
|
||||||
@@ -25,8 +36,20 @@ import { Message } from "@/components/ui/message";
|
|||||||
import { DanismanAvatar, DanismanBaslik } from "./sohbet-client";
|
import { DanismanAvatar, DanismanBaslik } from "./sohbet-client";
|
||||||
import { useUretimMesaji } from "./uretim-mesaji";
|
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 =
|
type Durum =
|
||||||
| { ad: "uretiliyor" }
|
| { 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: "kredi-yetersiz" }
|
||||||
| { ad: "hata"; mesaj: string }
|
| { ad: "hata"; mesaj: string }
|
||||||
| { ad: "profil-yok" };
|
| { ad: "profil-yok" };
|
||||||
@@ -57,45 +80,85 @@ export function ListeUretici({
|
|||||||
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
||||||
setDurum({ ad: "uretiliyor" });
|
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 {
|
try {
|
||||||
const sonuc = await listeOlustur({
|
sonuc = await listeOlustur({
|
||||||
sira: profil.sira,
|
sira: profil.sira,
|
||||||
tur: profil.tur,
|
tur: profil.tur,
|
||||||
secimler: profil.secimler,
|
secimler: profil.secimler,
|
||||||
requestId: requestIdRef.current,
|
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 {
|
} catch {
|
||||||
olay("liste_uretim_hata", { kod: "ISTISNA" });
|
olay("liste_uretim_hata", { kod: "ISTISNA" });
|
||||||
setDurum({
|
setDurum({
|
||||||
ad: "hata",
|
ad: "hata",
|
||||||
mesaj: "Beklenmeyen bir hata oluştu, tekrar dener misin?",
|
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(() => {
|
useEffect(() => {
|
||||||
if (basladi.current) return;
|
if (basladi.current) return;
|
||||||
basladi.current = true;
|
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();
|
const mesaj = useUretimMesaji();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -182,9 +245,32 @@ function UretimGovdesi() {
|
|||||||
<h1 className="font-heading text-xl font-bold sm:text-2xl">
|
<h1 className="font-heading text-xl font-bold sm:text-2xl">
|
||||||
Listen hazırlanıyor
|
Listen hazırlanıyor
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-slate-400">
|
{uzunSurdu ? (
|
||||||
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
|
<>
|
||||||
</p>
|
<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>
|
</header>
|
||||||
|
|
||||||
{/* Nihai sayfanın (listem-govde) grid'iyle aynı düzen: liste solda,
|
{/* Nihai sayfanın (listem-govde) grid'iyle aynı düzen: liste solda,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from "./sihirbaz-adimlar-lazy";
|
} from "./sihirbaz-adimlar-lazy";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { olay, siraKovasi } from "@/lib/analitik";
|
import { olay, siraKovasi } from "@/lib/analitik";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||||
@@ -65,16 +66,34 @@ const KANITLAR = [
|
|||||||
* profilsiz karttaki paragrafı değiştirir — katalog sayfaları kartı üstündeki
|
* 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
|
* 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.
|
* 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({
|
export function CtaSiraForm({
|
||||||
baslik,
|
baslik,
|
||||||
aciklama,
|
aciklama,
|
||||||
|
varyant = "kart",
|
||||||
|
konum = "alt",
|
||||||
}: {
|
}: {
|
||||||
baslik?: string;
|
baslik?: string;
|
||||||
aciklama?: 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 profil = useTercihProfili();
|
||||||
const router = useRouter();
|
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 [acik, setAcik] = useState(false);
|
||||||
const [adim, setAdim] = useState<"sira" | "sihirbaz">("sira");
|
const [adim, setAdim] = useState<"sira" | "sihirbaz">("sira");
|
||||||
@@ -87,8 +106,14 @@ export function CtaSiraForm({
|
|||||||
const [girisli, setGirisli] = useState(false);
|
const [girisli, setGirisli] = useState(false);
|
||||||
// Huni girişi ölçümü: ref yalnızca profilsiz daldaki karta bağlanır; kayıtlı
|
// 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
|
// profilli ziyaretçi formu hiç görmediğinden sayılmaz. Sayfa ayrımı için
|
||||||
// özellik gerekmez — Rybbit olayın pathname'ini zaten yazar.
|
// özellik gerekmez — Rybbit olayın pathname'ini zaten yazar. `konum` hem
|
||||||
const formRef = useGorunumOlayi<HTMLElement>("cta_form_goruntulendi");
|
// ö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)
|
// Yazarken binlik ayracıyla biçimle (85000 → 85.000)
|
||||||
function siralamaDegisti(ham: string) {
|
function siralamaDegisti(ham: string) {
|
||||||
@@ -118,13 +143,17 @@ export function CtaSiraForm({
|
|||||||
setSira(value);
|
setSira(value);
|
||||||
setFacetler(veri.facetler);
|
setFacetler(veri.facetler);
|
||||||
setAdim("sihirbaz");
|
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", {
|
olay("sira_girildi", {
|
||||||
kaynak: "cta",
|
kaynak: "cta",
|
||||||
|
konum,
|
||||||
tur,
|
tur,
|
||||||
sira_kovasi: siraKovasi(value),
|
sira_kovasi: siraKovasi(value),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
olay("sira_form_hata", { kaynak: "cta" });
|
olay("sira_form_hata", { kaynak: "cta", konum });
|
||||||
setHata("Bir şeyler ters gitti, tekrar dener misin?");
|
setHata("Bir şeyler ters gitti, tekrar dener misin?");
|
||||||
} finally {
|
} finally {
|
||||||
setYukleniyor(false);
|
setYukleniyor(false);
|
||||||
@@ -146,13 +175,219 @@ export function CtaSiraForm({
|
|||||||
function acikDegisti(v: boolean) {
|
function acikDegisti(v: boolean) {
|
||||||
setAcik(v);
|
setAcik(v);
|
||||||
if (!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.
|
// Yeniden açılışta sıfırdan başla; yazılan sıralama kaybolmasın.
|
||||||
setAdim("sira");
|
setAdim("sira");
|
||||||
setFacetler(null);
|
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) {
|
if (profil) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl border border-primary/20 bg-primary/5 p-6 sm:p-8">
|
<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 />
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Drawer open={acik} onOpenChange={acikDegisti} handleOnly>
|
{cekmece}
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
249
src/lib/kapanan-sluglar.ts
Normal file
249
src/lib/kapanan-sluglar.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user