Update package.json scripts, enhance layout with new components, and improve API error handling
Some checks failed
Deploy / deploy (push) Failing after 1h28m6s

- Added new scripts for database operations and temporary production in package.json.
- Integrated KaydetBannerLazy component into the layout for improved user notifications.
- Enhanced API error handling in the soru route to mark credit exhaustion.
- Updated the IletisimPage for better button styling and user experience.
- Refactored ListemPage to streamline user flow and improve session handling.
- Removed unused ListePaneli component to clean up the codebase.
This commit is contained in:
bilalgursen
2026-08-06 02:27:42 +03:00
parent 488845ae1f
commit e62c6f07df
77 changed files with 4132 additions and 1659 deletions

View File

@@ -0,0 +1,19 @@
import { NextResponse, type NextRequest } from "next/server";
import { netlerGetir } from "@/lib/db";
// Program satırındaki "netler" butonunun verisi: programa yerleşen son
// kişinin yıl bazlı netleri (bkz. components/program-netleri.tsx). Veri
// yılda bir değiştiği için istemci tarafında gönül rahatlığıyla cache'lenir.
export function GET(request: NextRequest) {
const id = request.nextUrl.searchParams.get("id") ?? "";
if (!/^\d{1,12}$/.test(id)) {
return NextResponse.json(
{ error: "Geçerli bir program kimliği gerekli." },
{ status: 400 },
);
}
return NextResponse.json(
{ netler: netlerGetir(id) },
{ headers: { "Cache-Control": "public, max-age=86400" } },
);
}

View File

@@ -2,7 +2,11 @@ import { eq, desc, asc } from "drizzle-orm";
import type { NextRequest } from "next/server";
import { getSession } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { spendCreditForMessage, grantCredits } from "@/lib/credits";
import {
spendCreditForMessage,
grantCredits,
krediBittiIsaretle,
} from "@/lib/credits";
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
import { sohbetAkisi } from "@/lib/ai/cagri";
import { listeOzetiCikar, type RaporSonuc } from "@/lib/ai/rapor";
@@ -147,6 +151,7 @@ export async function POST(request: NextRequest) {
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
await krediBittiIsaretle(session.user.id);
return Response.json(
{ error: "Kredin bitti.", code: "INSUFFICIENT" },
{ status: 402 },

View File

@@ -0,0 +1,38 @@
// Tadımlık satırı servisi: client sihirbaz kategorisini (localStorage'da
// yaşar, SSR göremez) bildirir; SEÇİM sunucuda yapılır (PRD kısıtı). Havuz
// batch üretilmiştir — bu uç LLM'e istek atmaz, salt SELECT'tir.
import { NextResponse } from "next/server";
import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
import { KATEGORILER } from "@/lib/kategoriler";
import { kategoriSlugBul, tadimlikSec } from "@/lib/tadimlik-havuzu";
const GECERLI_KATEGORI_SLUGLARI = new Set(KATEGORILER.map(kategoriSlugBul));
export async function GET(istek: Request) {
const url = new URL(istek.url);
const sira = Number.parseInt(url.searchParams.get("sira") ?? "", 10);
const tur = url.searchParams.get("tur") ?? "";
if (
!Number.isFinite(sira) ||
sira < 1 ||
sira > 4_000_000 ||
!(tur in PUAN_TURLERI)
) {
return NextResponse.json({ tadimlik: null }, { status: 400 });
}
const kategoriParam = url.searchParams.get("kategori");
const kategoriSlug =
kategoriParam && GECERLI_KATEGORI_SLUGLARI.has(kategoriParam)
? kategoriParam
: undefined;
const tadimlik = await tadimlikSec(sira, tur as PuanTuruKey, kategoriSlug);
return NextResponse.json(
{ tadimlik },
// Satırlar kova granülaritesinde statik; 1 saat cache yeterli.
{ headers: { "Cache-Control": "public, max-age=3600" } },
);
}

View File

@@ -25,7 +25,7 @@ export default function IletisimPage() {
</p>
<a
href="mailto:destek@kolaytercih.com"
className="mt-6 inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-900 shadow-xs transition-colors hover:border-slate-300 hover:bg-slate-50"
className="mt-6 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-900 shadow-xs transition-colors hover:border-slate-300 hover:bg-slate-50"
>
<Mail className="size-4 text-primary" aria-hidden />
destek@kolaytercih.com

View File

@@ -6,6 +6,7 @@ import { Toaster } from "@/components/ui/sonner";
import { SiteHeader } from "@/components/site-header";
import { SiteTopBanner } from "@/components/site-top-banner";
import { ListeCekmecesiLazy } from "@/components/manuel-liste/liste-cekmecesi-lazy";
import { KaydetBannerLazy } from "@/components/manuel-liste/kaydet-banner-lazy";
import { TercihProfiliKapisiLazy } from "@/components/manuel-liste/tercih-profili-kapisi-lazy";
import { HeaderGate } from "@/components/header-gate";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
@@ -124,6 +125,8 @@ export default function RootLayout({
</ScrollFlow>
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
<ListeCekmecesiLazy />
{/* Girişsiz + 3 seçimli kullanıcıya "listen yalnızca bu tarayıcıda" banner'ı */}
<KaydetBannerLazy />
{/* İlk "+" için sıra + sihirbaz kapısı */}
<TercihProfiliKapisiLazy />
<Toaster position="top-center" />

View File

@@ -0,0 +1,208 @@
"use client";
// /listem üretim gövdesi: sihirbazın kaydettiği bekleyen seçimlerle Yapay
// Zeka listesi burada kurulur (sonuç sayfası yalnızca buraya yönlendirir).
// Başarıda profil tüketilir ve sayfa yenilenir; kredi-yetersiz ve hata
// durumları da bu bileşende karşılanır.
import { useEffect, useRef, useState, type ReactNode } from "react";
import Link from "next/link";
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 "@/app/sonuc/actions";
import { olay, siraKovasi } from "@/lib/analitik";
import {
sonucHref,
tercihProfiliOku,
tercihProfiliSil,
type TercihProfili,
} from "@/lib/sihirbaz";
const URETIM_MESAJLARI = [
"Sıralamana uygun programlar taranıyor…",
"Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…",
"Her tercih için risk ve trend analizi yazılıyor…",
"Son rötuşlar yapılıyor, neredeyse hazır…",
];
type Durum =
| { ad: "uretiliyor" }
| { ad: "kredi-yetersiz" }
| { ad: "hata"; mesaj: string }
| { ad: "profil-yok" };
export function ListeUretici({
hasPaket,
bosDurum,
}: {
hasPaket: boolean;
/** Storage'da bekleyen seçim yoksa gösterilecek boş-liste durumu */
bosDurum: ReactNode;
}) {
const router = useRouter();
const [durum, setDurum] = useState<Durum>({ ad: "uretiliyor" });
const [profil, setProfil] = useState<TercihProfili | null>(null);
// Aynı deneme için sabit requestId: cevap kaybolan network hatasında retry
// çifte harcamaz. Sunucudan cevap gelince sıfırlanır (harcama ya tamamlandı
// ya iade edildi; sonraki deneme taze id ile başlamalı — bkz. sonuc/actions.ts).
const requestIdRef = useRef<string | null>(null);
const basladi = useRef(false);
async function uret(profil: TercihProfili) {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
setDurum({ ad: "uretiliyor" });
try {
const 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),
});
tercihProfiliSil();
// ?uret=1 URL'de kalmasın: yenileme/geri dönüş üretimi tekrar
// tetiklemesin. 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 {
setDurum({ ad: "hata", mesaj: sonuc.error });
}
} catch {
setDurum({
ad: "hata",
mesaj: "Beklenmeyen bir hata oluştu, tekrar dener misin?",
});
}
}
useEffect(() => {
if (basladi.current) return;
basladi.current = true;
// Bekleyen seçimler yalnızca client storage'da; mount sonrası okunur.
const kayitli = tercihProfiliOku();
if (!kayitli) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDurum({ ad: "profil-yok" });
return;
}
setProfil(kayitli);
void uret(kayitli);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (durum.ad === "profil-yok") return <>{bosDurum}</>;
if (durum.ad === "kredi-yetersiz") {
// Paketli kullanıcıya paket satma: onun ürünü top-up. Liste ve sohbet
// geçmişi kalıcı; kredi yükleyince kaldığı yerden devam eder.
return (
<section className="rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<Lock className="mx-auto size-8 text-slate-400" aria-hidden />
<h1 className="mt-4 font-heading text-xl font-bold">
Kredin bu liste için yetmiyor
</h1>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
{hasPaket
? "Liste üretimi 3 kredi. +30 kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin."
: "Liste üretimi 3 kredi. Tercih Dönemi Paketi'yle 60 kredi, tam liste, PDF ve revizyon hakları birlikte gelir."}
</p>
<Button
asChild
size="lg"
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/paket">
{hasPaket ? "+30 kredi yükle — 129 TL" : "Paketi aktive et — 299 TL"}
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</section>
);
}
if (durum.ad === "hata") {
return (
<section className="rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<h1 className="font-heading text-xl font-bold">Liste kurulamadı</h1>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
{durum.mesaj}
</p>
<Button
size="lg"
onClick={() => profil && void uret(profil)}
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<RefreshCcw className="size-4" aria-hidden />
Tekrar dene
</Button>
{profil ? (
<p className="mt-4 text-xs text-slate-500">
Seçimlerin kayıtlı {" "}
<Link href={sonucHref(profil)} className="underline">
sonuç sayfasına dönebilirsin
</Link>
.
</p>
) : null}
</section>
);
}
return <UretimGovdesi />;
}
function UretimGovdesi() {
const [mesajIdx, setMesajIdx] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
}, 7000);
return () => clearInterval(interval);
}, []);
return (
<section
aria-live="polite"
className="rounded-2xl border border-slate-200 bg-white p-6 sm:p-8"
>
<div className="flex flex-col items-center gap-1.5 text-center">
<h1 className="font-heading text-lg font-bold">Listen hazırlanıyor</h1>
<p className="max-w-sm text-sm text-slate-500">
{URETIM_MESAJLARI[mesajIdx]}
</p>
<p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p>
</div>
{/* Gelmekte olan tercih listesinin iskeleti */}
<div className="mt-8 flex flex-col gap-2">
<div className="flex items-center justify-between">
<Skeleton className="h-6 w-44" />
<Skeleton className="h-6 w-24 rounded-full" />
</div>
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-xl" />
))}
</div>
</section>
);
}

View File

@@ -26,7 +26,7 @@ export function ListemBosCta() {
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white hover:bg-orange-600"
className="mt-6 cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
<Link href={profil ? sonucHref(profil, { sihirbaz: "1" }) : "/"}>
{profil ? "Yapay Zeka listeni kur" : "Sıralamanı gir, Yapay Zeka listeni kur"}

View File

@@ -0,0 +1,283 @@
"use client";
// /listem gövdesi: Türkiye haritası + 24'lük liste + danışman sohbeti tek
// ekranda. Harita ⇄ liste köprüsü (pinden satıra, satırdan haritaya) ve
// paketsiz paywall kartları burada; veri çekimi sunucuda (page.tsx) kalır.
import { useMemo, useRef, useState } from "react";
import Link from "next/link";
import {
ArrowRight,
Bot,
Check,
FileText,
Lock,
PencilLine,
ShieldCheck,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { RaporListesi } from "@/components/rapor-listesi";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { TercihHaritasi } from "@/components/tercih-haritasi";
import { pinleriTuret } from "@/lib/harita-pinler";
import type { RaporSonuc } from "@/lib/ai/rapor";
import { SohbetClient, type DanismanOzeti, type Mesaj } from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu";
export function ListemGovde({
rapor,
adaySira,
hasPaket,
kilitAcilisi,
kilitliBaslangic,
kredi,
sohbetGecmisi,
ozet,
kalanHak,
paketFiyati,
paketKredi,
}: {
rapor: RaporSonuc;
adaySira: number;
hasPaket: boolean;
/** Ödeme dönüşü: blur çözülme animasyonuyla aç */
kilitAcilisi: boolean;
/** Paketsizde ACIK_SATIR; paketlide undefined (tamamıık) */
kilitliBaslangic?: number;
kredi: number;
sohbetGecmisi: Mesaj[];
ozet?: DanismanOzeti;
kalanHak: number;
paketFiyati: string;
paketKredi: number;
}) {
// Harita ⇄ liste köprüsü: pinden satıra, satırdan haritaya
const [seciliIl, setSeciliIl] = useState<string | null>(null);
const [acikSira, setAcikSira] = useState<number | null>(null);
const haritaRef = useRef<HTMLElement>(null);
const pinVerisi = useMemo(
() =>
pinleriTuret(
rapor,
adaySira,
kilitliBaslangic ?? rapor.tercihler.length,
),
[rapor, adaySira, kilitliBaslangic],
);
const acikSatir = kilitliBaslangic ?? rapor.tercihler.length;
// Kapı ölçümü: paywall yüzeyleri viewport'a girince (oturumda 1'er kez).
// Ref'ler yalnızca paketsiz dallarda bağlanır; paketlide DOM'da olmadıkları
// için observer hiç kurulmaz.
const kilitSatirRef = useGorunumOlayi<HTMLDivElement>(
"kilit_goruntulendi",
{ yer: "satir" },
"satir",
);
const kilitKartRef = useGorunumOlayi<HTMLElement>(
"kilit_goruntulendi",
{ yer: "kart" },
"kart",
);
return (
<div className="mt-8 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_360px] xl:grid-cols-[minmax(0,1fr)_400px]">
<div className="flex min-w-0 flex-col gap-6">
{/* Harita: listenin coğrafyası — ile dokun, üniversiteleri gör */}
<section
ref={haritaRef}
aria-label="Tercih listesinin Türkiye haritası"
className="scroll-mt-24"
>
<div className="mx-auto w-full max-w-xl">
<TercihHaritasi
pinler={pinVerisi.pinler}
seciliIl={seciliIl}
onSeciliIlDegisti={setSeciliIl}
onTercihSec={setAcikSira}
/>
</div>
{pinVerisi.haritaDisi > 0 ? (
<p className="mt-1 text-center text-xs text-slate-500">
{pinVerisi.haritaDisi} tercih harita dışında (KKTC vb.)
</p>
) : null}
</section>
{/* Liste: 24 tercih ve taban verileri. Sayfa başlığı ve revizyon/PDF
aksiyonları bu kutunun kendi başlık satırında yaşar. */}
<section className={`min-w-0${kilitAcilisi ? " kt-unblur" : ""}`}>
<div className="flex flex-wrap items-center justify-between gap-3 pb-1">
<h1 className="min-w-0 font-heading text-xl font-bold tracking-tight sm:text-2xl">
{hasPaket
? "Yapay Zeka tercih listen"
: "Veriyi ücretsiz incele. Kararı Yapay Zeka ile netleştir."}
</h1>
{hasPaket ? (
<div className="flex shrink-0 gap-2">
<Button
asChild
variant="outline"
size="sm"
className="cursor-pointer"
>
<Link href="#revizyon">
<PencilLine className="size-4" aria-hidden />
Revize et
</Link>
</Button>
<Button
asChild
variant="outline"
size="sm"
className="cursor-pointer"
>
<Link href="/rapor/yazdir" target="_blank">
<FileText className="size-4" aria-hidden />
PDF
</Link>
</Button>
</div>
) : null}
</div>
<RaporListesi
rapor={rapor}
adaySira={adaySira}
acikSira={acikSira}
onHaritadaGor={(il) => {
if (!il) return;
setSeciliIl(il);
haritaRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}}
kilitliBaslangic={kilitliBaslangic}
kilitOverlay={
<div
ref={kilitSatirRef}
className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-700">
<Lock className="size-4" aria-hidden />
</span>
<div>
<p className="font-heading font-bold text-slate-900">
İlk {acikSatir} tercihte Yapay Zeka analizini gördün
</p>
<p className="mt-1 max-w-xl text-sm text-slate-600">
Kalan {rapor.tercihler.length - acikSatir} satırda senin
sıralamana özel gerekçe ve risk notu hazır paketle
ılır. Program adları ve taban verileri her zaman
ücretsiz.
</p>
</div>
</div>
<Button
asChild
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
>
<Link href="/paket">
Analizleri {paketFiyati} TL
<ArrowRight data-icon="inline-end" aria-hidden />
</Link>
</Button>
</div>
}
/>
</section>
{hasPaket ? <RevizyonKutusu kalanHak={kalanHak} /> : null}
</div>
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
{hasPaket ? (
<SohbetClient
baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi}
ozet={ozet}
/>
) : (
<div className="flex flex-col gap-6">
{/* Sohbet paket kartının üstünde: #sohbet çapası (ve mobildeki
"Danışmana sor" butonu) doğrudan danışmana düşsün. */}
<SohbetClient
baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi}
ozet={ozet}
className="h-[420px]"
/>
<section
ref={kilitKartRef}
className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"
>
<div className="border-b border-slate-100 p-6">
<h3 className="font-heading text-xl font-bold tracking-tight">
Tablo ücretsiz.
<br />
Karar desteği pakette.
</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
Sadece veriyi göstermez; hangi tercihin neden mantıklı
olduğunu sıralamana göre ıklar.
</p>
</div>
<div className="p-6">
<ul className="flex flex-col gap-3 text-sm">
{[
"24 tercihin tamamında kişisel risk analizi",
"Her program için kısa, gerekçeli Yapay Zeka yorumu",
"2 liste revizyonu ve paylaşılabilir PDF",
`${paketKredi} Yapay Zeka danışman sorusu`,
].map((ozellik) => (
<li key={ozellik} className="flex items-start gap-2.5">
<Check
className="mt-0.5 size-4 shrink-0 text-emerald-600"
aria-hidden
/>
<span>{ozellik}</span>
</li>
))}
</ul>
<div className="mt-6">
<p className="text-xs text-slate-500">Tek seferlik</p>
<p className="font-heading text-3xl font-bold">
{paketFiyati} TL
</p>
</div>
<Button
asChild
size="lg"
className="mt-5 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
>
<Link href="/paket">
Kişisel analizi
<ArrowRight data-icon="inline-end" aria-hidden />
</Link>
</Button>
<div className="mt-4 flex items-center justify-center gap-4 text-[11px] text-slate-500">
<span className="inline-flex items-center gap-1">
<ShieldCheck className="size-3.5" aria-hidden />
iyzico güvenli ödeme
</span>
<span className="inline-flex items-center gap-1">
<Bot className="size-3.5" aria-hidden />
Gerçek YÖK verisi
</span>
</div>
</div>
</section>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,33 +1,19 @@
import type { Metadata } from "next";
import Link from "next/link";
import { asc, eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import {
ArrowRight,
Bot,
Check,
FileText,
Lock,
MessageCircleQuestion,
PencilLine,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { MessageCircleQuestion, Sparkles } from "lucide-react";
import { verifySession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { PUAN_TURLERI } from "@/lib/db";
import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { Button } from "@/components/ui/button";
import { RaporListesi } from "@/components/rapor-listesi";
import { ViewportPortal } from "@/components/viewport-portal";
import { SiteFooter } from "@/components/site-footer";
import { SectionEyebrow } from "@/components/pixel-decor";
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
import { SohbetClient, type Mesaj } from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu";
import { raporMaskele, ACIK_SATIR, type MaskeliRapor } from "@/lib/rapor-maske";
import type { Mesaj } from "./sohbet-client";
import { ListemGovde } from "./listem-govde";
import { ListemBosCta } from "./listem-bos-cta";
import { ListeUretici } from "./liste-uretici";
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort
// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA
@@ -46,9 +32,13 @@ const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
export default async function ListemPage({
searchParams,
}: {
searchParams: Promise<{ acildi?: string }>;
searchParams: Promise<{ acildi?: string; uret?: string }>;
}) {
const session = await verifySession("/listem");
// Girişsiz gelene callback'i ?uret=1 ile ver: girişten dönüşte bekleyen
// seçimlerle üretim kaldığı yerden başlasın.
const ilkParams = await searchParams;
const geriYol = ilkParams.uret === "1" ? "/listem?uret=1" : "/listem";
const session = await verifySession(geriYol);
const [user, params, satir, sohbetGecmisi] = await Promise.all([
getCurrentUser(),
searchParams,
@@ -66,46 +56,60 @@ export default async function ListemPage({
.orderBy(asc(schema.chatMessages.createdAt))
.limit(100),
]);
if (!user) redirect("/giris?callback=/listem");
if (!user) redirect(`/giris?callback=${encodeURIComponent(geriYol)}`);
const { acildi } = params;
const kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
const raporVar = Boolean(satir?.result);
if (!raporVar) {
const bosDurum = (
<section className="mx-auto flex max-w-2xl flex-col items-center px-4 py-16 text-center">
<Sparkles className="size-10 text-primary" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold">
Henüz Yapay Zeka listen yok
</h1>
<ListemBosCta />
</section>
);
return (
<>
<main className="mx-auto w-full max-w-4xl px-4 py-12">
<section className="mx-auto flex max-w-2xl flex-col items-center px-4 py-16 text-center">
<Sparkles className="size-10 text-primary" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold">
Henüz Yapay Zeka listen yok
</h1>
<ListemBosCta />
</section>
{/* ?uret=1: sihirbazdan yönlendirildi — bekleyen seçimlerle üretim
burada koşar, animasyon ve hata/kredi durumları burada yaşar. */}
{params.uret === "1" ? (
<ListeUretici
hasPaket={Boolean(user.hasPaket)}
bosDurum={bosDurum}
/>
) : (
bosDurum
)}
</main>
<SiteFooter />
</>
);
}
const rapor = user.hasPaket
const rapor: MaskeliRapor = user.hasPaket
? (satir!.result as RaporSonuc)
: raporMaskele(satir!.result as RaporSonuc);
const raporParams = satir!.params as RaporParams;
// Danışman özeti sohbetin açılış mesajı olarak gösterilir; sohbet
// /api/soru üzerinden zaten liste + seçim bağlamıyla çalışıyor.
// Paketsizde rapor zaten maskeli: 1 uyarıık, kalanının yalnızca sayısı var.
const danismanOzeti = rapor.genelDegerlendirme
? {
metin: rapor.genelDegerlendirme,
uyarilar: user.hasPaket ? rapor.uyarilar : undefined,
uyarilar: rapor.uyarilar,
kilitli: !user.hasPaket,
kilitliUyariSayisi: user.hasPaket ? undefined : rapor.kilitliUyariSayisi,
}
: undefined;
return (
<>
<main className="mx-auto w-full max-w-4xl px-4 py-12">
<main className="mx-auto w-full max-w-7xl px-4 py-12">
{kilitAcilisi ? (
<div
role="status"
@@ -116,184 +120,21 @@ export default async function ListemPage({
artık ık.
</div>
) : null}
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
{user.hasPaket ? (
<SectionEyebrow className="mb-3">Paketin aktif</SectionEyebrow>
) : null}
<h1 className="max-w-2xl font-heading text-xl font-bold tracking-tight sm:text-2xl">
{user.hasPaket
? "Yapay Zeka tercih listen"
: "Veriyi ücretsiz incele. Kararı Yapay Zeka ile netleştir."}
</h1>
{user.hasPaket ? (
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
{raporParams.sira.toLocaleString("tr-TR")}. sıra ·{" "}
{PUAN_TURLERI[raporParams.tur as keyof typeof PUAN_TURLERI] ??
raporParams.tur}{" "}
· 24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle
aşağıda inceleyebilirsin.
</p>
) : null}
</div>
{user.hasPaket ? (
<div className="flex gap-2">
<Button
asChild
variant="outline"
size="sm"
className="cursor-pointer"
>
<Link href="#revizyon">
<PencilLine className="size-4" aria-hidden />
Revize et
</Link>
</Button>
<Button
asChild
variant="outline"
size="sm"
className="cursor-pointer"
>
<Link href="/rapor/yazdir" target="_blank">
<FileText className="size-4" aria-hidden />
PDF
</Link>
</Button>
</div>
) : null}
</div>
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="flex min-w-0 flex-col gap-6">
<section
className={`min-w-0 rounded-2xl border border-slate-200 bg-slate-50/60 p-3 sm:p-5${
kilitAcilisi ? " kt-unblur" : ""
}`}
>
<RaporListesi
rapor={rapor}
adaySira={raporParams.sira}
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
kilitOverlay={
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-700">
<Lock className="size-4" aria-hidden />
</span>
<div>
<p className="font-heading font-bold text-slate-900">
İlk {ACIK_SATIR} tercihte Yapay Zeka analizini gördün
</p>
<p className="mt-1 max-w-xl text-sm text-slate-600">
Kalan {24 - ACIK_SATIR} tercihin kişisel risk, gerekçe ve
Yapay Zeka yorumu paketle ılır. Program adları ve taban
verileri her zaman ücretsiz.
</p>
</div>
</div>
<Button
asChild
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
>
<Link href="/paket">
Analizleri {PAKET_FIYATI} TL
<ArrowRight data-icon="inline-end" aria-hidden />
</Link>
</Button>
</div>
}
/>
</section>
{user.hasPaket ? (
<RevizyonKutusu
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
/>
) : null}
</div>
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
{user.hasPaket ? (
<SohbetClient
baslangicKredi={user.creditBalance}
initialMesajlar={sohbetGecmisi as Mesaj[]}
ozet={danismanOzeti}
/>
) : (
<div className="flex flex-col gap-6">
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 p-6">
<h3 className="font-heading text-xl font-bold tracking-tight">
Tablo ücretsiz.
<br />
Karar desteği pakette.
</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
Sadece veriyi göstermez; hangi tercihin neden mantıklı
olduğunu sıralamana göre ıklar.
</p>
</div>
<div className="p-6">
<ul className="flex flex-col gap-3 text-sm">
{[
"24 tercihin tamamında kişisel risk analizi",
"Her program için kısa, gerekçeli Yapay Zeka yorumu",
"2 liste revizyonu ve paylaşılabilir PDF",
`${URUNLER.paket.credits} Yapay Zeka danışman sorusu`,
].map((ozellik) => (
<li key={ozellik} className="flex items-start gap-2.5">
<Check
className="mt-0.5 size-4 shrink-0 text-emerald-600"
aria-hidden
/>
<span>{ozellik}</span>
</li>
))}
</ul>
<div className="mt-6">
<p className="text-xs text-slate-500">Tek seferlik</p>
<p className="font-heading text-3xl font-bold">
{PAKET_FIYATI} TL
</p>
</div>
<Button
asChild
size="lg"
className="mt-5 h-12 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
>
<Link href="/paket">
Kişisel analizi
<ArrowRight data-icon="inline-end" aria-hidden />
</Link>
</Button>
<div className="mt-4 flex items-center justify-center gap-4 text-[11px] text-slate-500">
<span className="inline-flex items-center gap-1">
<ShieldCheck className="size-3.5" aria-hidden />
iyzico güvenli ödeme
</span>
<span className="inline-flex items-center gap-1">
<Bot className="size-3.5" aria-hidden />
Gerçek YÖK verisi
</span>
</div>
</div>
</section>
<SohbetClient
baslangicKredi={user.creditBalance}
initialMesajlar={sohbetGecmisi as Mesaj[]}
ozet={danismanOzeti}
className="h-[520px]"
/>
</div>
)}
</div>
</div>
{/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında
(bkz. listem-govde.tsx) — sayfa üstünde ayrı bir header yok. */}
<ListemGovde
rapor={rapor}
adaySira={raporParams.sira}
hasPaket={Boolean(user.hasPaket)}
kilitAcilisi={kilitAcilisi}
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
kredi={user.creditBalance}
sohbetGecmisi={sohbetGecmisi as Mesaj[]}
ozet={danismanOzeti}
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
paketFiyati={PAKET_FIYATI}
paketKredi={URUNLER.paket.credits}
/>
<ViewportPortal>
<a

View File

@@ -3,7 +3,8 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AlertTriangle, ArrowUp, Bot, Coins, Lock, Sparkles } from "lucide-react";
import Image from "next/image";
import { AlertTriangle, ArrowUp, Coins, Lock, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@@ -35,6 +36,26 @@ export interface DanismanOzeti {
metin: string;
uyarilar?: string[];
kilitli?: boolean;
/** Paketsizde açık uyarının ardından kilitli kalan uyarı sayısı. */
kilitliUyariSayisi?: number;
}
/** Danışman mesajlarının avatarı — KolayTercih markası. */
function DanismanAvatar() {
return (
<span
className="mt-0.5 flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-slate-200 bg-white"
aria-hidden
>
<Image
src="/kolay-tercih-mark.svg"
alt=""
width={32}
height={32}
className="size-5"
/>
</span>
);
}
const ORNEK_SORULAR = [
@@ -157,7 +178,7 @@ export function SohbetClient({
aria-label="Danışman sohbeti"
className={cn(
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white",
className ?? "h-[calc(100dvh-9rem)] min-h-[480px]",
className ?? "h-[calc(100dvh-16rem)] min-h-[400px]",
)}
>
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
@@ -177,9 +198,9 @@ export function SohbetClient({
<ChatContainerContent className="gap-4 p-4">
{ozet ? (
<Message className="justify-start">
<DanismanAvatar />
<div className="flex max-w-[85%] flex-col gap-2">
<p className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
<Bot className="size-3.5 text-primary" aria-hidden />
<p className="text-xs font-semibold text-slate-500">
Danışman özeti
</p>
<MessageContent className="bg-slate-100 text-slate-800">
@@ -204,8 +225,17 @@ export function SohbetClient({
{ozet.kilitli ? (
<p className="flex items-center gap-1.5 text-xs text-slate-500">
<Lock className="size-3.5" aria-hidden />
Değerlendirmenin tamamı ve sıralamana özel uyarılar
pakette.
{ozet.kilitliUyariSayisi ? (
<>
{ozet.kilitliUyariSayisi} uyarı daha kilitli
sıralamana özel uyarıların pakette.
</>
) : (
<>
Değerlendirmenin tamamı ve sıralamana özel uyarılar
pakette.
</>
)}
</p>
) : null}
</div>
@@ -281,6 +311,7 @@ export function SohbetClient({
</Message>
) : (
<Message key={m.id} className="justify-start">
<DanismanAvatar />
{m.content ? (
<MessageContent
markdown

View File

@@ -7,6 +7,7 @@ import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
import {
spendCredits,
grantCredits,
krediBittiIsaretle,
RAPOR_KREDI,
MAX_REVIZYON,
} from "@/lib/credits";
@@ -113,6 +114,7 @@ export async function listeOlustur(input: {
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
await krediBittiIsaretle(user.id);
return {
ok: false,
code: "KREDI",
@@ -241,6 +243,7 @@ export async function listeRevize(input: {
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
await krediBittiIsaretle(user.id);
return {
ok: false,
code: "KREDI",

View File

@@ -1,401 +0,0 @@
"use client";
import { useMemo, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ArrowRight, FileText, Lock, RefreshCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { SohbetPopup } from "./sohbet-popup";
import { RaporListesi } from "@/components/rapor-listesi";
import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { ACIK_SATIR } from "@/lib/rapor-maske";
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
import { dilimdenRisk, riskHesapla, type RiskSeviyesi } from "@/lib/risk";
const MAX_REVIZYON = 2;
export type MevcutRapor = {
rapor: RaporSonuc;
params: RaporParams;
revisionCount: number;
};
/** Panelin liste dışındaki geçici durumları (liste, rapor varlığından türer). */
export type PanelDurum = "bos" | "uretiliyor" | "kredi-yetersiz";
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
guvenli: 0,
"az-riskli": 1,
riskli: 2,
};
/**
* Rapordan harita pinleri türetir. Açık satırlar üniversite bazında gruplanır
* (kampüs konumunda, adıyla); kilitli satırlar il başına tek adsız "?" pinine
* toplanır — maskeli veride üniversite adı zaten client'a inmediği için
* harita paywall'ı delemez. Haritada karşılığı olmayan tercihler sayılır.
*/
function pinleriTuret(
rapor: RaporSonuc,
adaySira: number,
kilitliBaslangic: number,
): { pinler: HaritaPin[]; haritaDisi: number } {
const gruplar = new Map<string, HaritaPin>();
let haritaDisi = 0;
rapor.tercihler.forEach((t, i) => {
const p = rapor.programlar[t.programId];
if (!p) return;
const risk = riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim);
const kilitli = i >= kilitliBaslangic;
if (kilitli) {
const konum = ilKonum(p.il);
if (!konum) {
haritaDisi += 1;
return;
}
const key = `kilit|${p.il}`;
const mevcut = gruplar.get(key);
if (mevcut) {
mevcut.tercihSiralari.push(t.sira);
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
} else {
// Açık pinlerle üst üste binmesin diye il merkezinin hafif altına
gruplar.set(key, {
id: key,
universite: "",
il: p.il,
x: konum.x,
y: konum.y + 9,
tercihSiralari: [t.sira],
risk,
kilitli: true,
});
}
return;
}
const konum = uniKonum(p.universite, p.il);
if (!konum) {
haritaDisi += 1;
return;
}
const key = `${uniAdiNormalize(p.universite)}|${p.il ?? ""}`;
const mevcut = gruplar.get(key);
if (mevcut) {
mevcut.tercihSiralari.push(t.sira);
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
} else {
gruplar.set(key, {
id: key,
universite: p.universite,
il: p.il,
x: konum.x,
y: konum.y,
tercihSiralari: [t.sira],
risk,
});
}
});
return { pinler: [...gruplar.values()], haritaDisi };
}
const URETIM_MESAJLARI = [
"Sıralamana uygun programlar taranıyor…",
"Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…",
"Her tercih için risk ve trend analizi yazılıyor…",
"Son rötuşlar yapılıyor, neredeyse hazır…",
];
/**
* Sonuç sayfası gövdesindeki liste paneli: üretim animasyonu, paketli tam
* liste veya paketsiz progressive-blur önizleme burada render edilir.
* (Sihirbaz modalı yalnızca adımları toplar; sonuç artık sayfanın kendisinde.)
*/
export function ListePaneli({
durum,
rapor,
hasPaket,
kredi,
sira,
acilis,
onRevize,
}: {
durum: PanelDurum;
rapor: MevcutRapor | null;
hasPaket: boolean;
kredi: number;
sira: number;
/** Ödeme dönüşü: blur çözülme animasyonuyla aç */
acilis?: boolean;
onRevize: (feedback: string) => Promise<boolean>;
}) {
const kilitli = !hasPaket;
// Harita ⇄ liste köprüsü: pinden satıra, satırdan haritaya
const [seciliIl, setSeciliIl] = useState<string | null>(null);
const [acikSira, setAcikSira] = useState<number | null>(null);
const haritaRef = useRef<HTMLDivElement>(null);
const pinVerisi = useMemo(
() =>
rapor
? pinleriTuret(
rapor.rapor,
rapor.params.sira,
kilitli ? ACIK_SATIR : rapor.rapor.tercihler.length,
)
: null,
[rapor, kilitli],
);
if (durum === "uretiliyor") return <UretimGovdesi />;
if (durum === "kredi-yetersiz") {
// Paketli kullanıcıya paket satma: onun ürünü top-up. Liste ve sohbet
// geçmişi kalıcı; kredi yükleyince kaldığı yerden devam eder.
return (
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<Lock className="mx-auto size-8 text-slate-400" aria-hidden />
<h2 className="mt-4 font-heading text-xl font-bold">
Kredin bu liste için yetmiyor
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
{hasPaket
? "Liste üretimi 3 kredi. +30 kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin."
: "Liste üretimi 3 kredi. Tercih Dönemi Paketi'yle 60 kredi, tam liste, PDF ve revizyon hakları birlikte gelir."}
</p>
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/paket">
{hasPaket ? "+30 kredi yükle — 129 TL" : "Paketi aktive et — 299 TL"}
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</section>
);
}
if (!rapor) return null;
return (
<section
className={`mt-16 ${acilis && !kilitli ? "kt-unblur" : ""}`}
aria-label="Tercih listen"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-heading text-2xl font-bold">
24 Tercihlik Listen
</h2>
<p className="mt-1 text-sm text-slate-500">
{sira.toLocaleString("tr-TR")}. sıra ·{" "}
{kilitli
? `tüm taban verileri açık · ${ACIK_SATIR} Yapay Zeka analiz örneği`
: `${kredi} kredin kaldı`}
</p>
</div>
{!kilitli ? (
<Button asChild variant="outline" className="cursor-pointer">
<Link href="/rapor/yazdir" target="_blank">
<FileText className="size-4" aria-hidden />
PDF indir
</Link>
</Button>
) : null}
</div>
{/* Harita: 24 tercihin coğrafyası — ile dokun, üniversiteleri gör */}
<div ref={haritaRef} className="mt-5 scroll-mt-24">
<TercihHaritasi
pinler={pinVerisi?.pinler ?? []}
seciliIl={seciliIl}
onSeciliIlDegisti={setSeciliIl}
onTercihSec={setAcikSira}
/>
{pinVerisi && pinVerisi.haritaDisi > 0 ? (
<p className="mt-1 px-1 text-xs text-slate-500">
{pinVerisi.haritaDisi} tercih harita dışında (KKTC vb.)
</p>
) : null}
</div>
<div className="mt-5">
<RaporListesi
rapor={rapor.rapor}
adaySira={rapor.params.sira}
acikSira={acikSira}
onHaritadaGor={(il) => {
if (!il) return;
setSeciliIl(il);
haritaRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}}
kilitliBaslangic={kilitli ? ACIK_SATIR : undefined}
kilitOverlay={
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex gap-3">
<Lock
className="mt-1 size-5 shrink-0 text-orange-700"
aria-hidden
/>
<div>
<p className="font-heading font-bold text-slate-900">
Tüm program ve taban verileri ücretsiz
</p>
<p className="mt-1 max-w-xl text-sm text-slate-600">
Paket; kalan {24 - ACIK_SATIR} tercih için kişisel risk,
gerekçe, PDF, 2 revizyon ve 60 Yapay Zeka danışman sorusunu açar.
</p>
{kredi > 0 ? (
<p className="mt-1 text-xs text-slate-500">
Kalan {kredi} deneme kredin hesabında duruyor.
</p>
) : null}
</div>
</div>
<Button
asChild
size="lg"
className="h-12 w-full shrink-0 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
>
<Link href="/paket">299 TL Analizleri </Link>
</Button>
</div>
}
/>
</div>
{!kilitli ? (
<div className="mt-6">
<RevizyonKutusu
kalanHak={MAX_REVIZYON - rapor.revisionCount}
onRevize={onRevize}
/>
</div>
) : null}
{/* Yapay Zeka danışman: sağ altta yüzen buton, alttan popup olarak açılır */}
<SohbetPopup kredi={kredi} />
</section>
);
}
function UretimGovdesi() {
const [mesajIdx, setMesajIdx] = useState(0);
const govdeRef = useRef<HTMLElement>(null);
useEffect(() => {
const interval = setInterval(() => {
setMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
}, 7000);
return () => clearInterval(interval);
}, []);
// Modal geçiş anında unmount edildiğinden Radix odağı tetikleyiciye geri
// veremiyor; odağı panele alıp klavye ve ekran okuyucu akışını sürdürüyoruz.
useEffect(() => {
govdeRef.current?.focus({ preventScroll: true });
}, []);
return (
<section
ref={govdeRef}
tabIndex={-1}
aria-live="polite"
className="mt-16 rounded-2xl border border-slate-200 bg-white p-6 outline-none sm:p-8"
>
<div className="flex flex-col items-center gap-1.5 text-center">
<p className="font-heading text-lg font-bold">Listen hazırlanıyor</p>
<p className="max-w-sm text-sm text-slate-500">
{URETIM_MESAJLARI[mesajIdx]}
</p>
<p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p>
</div>
{/* Gelmekte olan tercih listesinin iskeleti */}
<div className="mt-8 flex flex-col gap-2">
<div className="flex items-center justify-between">
<Skeleton className="h-6 w-44" />
<Skeleton className="h-6 w-24 rounded-full" />
</div>
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-xl" />
))}
</div>
</section>
);
}
function RevizyonKutusu({
kalanHak,
onRevize,
}: {
kalanHak: number;
onRevize: (feedback: string) => Promise<boolean>;
}) {
const [feedback, setFeedback] = useState("");
const [pending, setPending] = useState(false);
if (kalanHak <= 0) {
return (
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<p className="text-sm text-slate-500">
Revizyon hakların doldu. Aşağıdan listen hakkında soru sorabilirsin.
</p>
</div>
);
}
return (
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<h3 className="font-heading text-base font-bold">
Listede değişiklik mi istiyorsun?
</h3>
<p className="mb-3 mt-1 text-sm text-slate-600">
Ne değişsin istediğini yaz; liste aynı gerçek veriyle yeniden kurulsun
(3 kredi).
</p>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={2}
maxLength={1000}
placeholder="ör. İlk 5 tercihte daha fazla İstanbul olsun, garanti dilimini genişlet…"
disabled={pending}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
<div className="mt-3 flex items-center gap-3">
<Button
type="button"
variant="outline"
disabled={pending || feedback.trim().length < 5}
onClick={async () => {
setPending(true);
const ok = await onRevize(feedback.trim());
setPending(false);
if (ok) setFeedback("");
}}
className="cursor-pointer"
>
<RefreshCcw className="size-4" aria-hidden />
{pending ? "Yeniden kuruluyor…" : "Revize et (3 kredi)"}
</Button>
<span className="text-xs text-slate-500">
Kalan hak: <span className="font-semibold">{kalanHak}</span>
</span>
</div>
</div>
);
}

View File

@@ -6,23 +6,16 @@ import {
searchByRank,
type PuanTuruKey,
} from "@/lib/db";
import { getSession, getCurrentUser } from "@/lib/session";
import { getSession } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { raporMaskele } from "@/lib/rapor-maske";
import { tadimlikSec } from "@/lib/tadimlik-havuzu";
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
import type { MevcutRapor } from "./liste-paneli";
import { TadimlikSatiri } from "./tadimlik-satiri";
import { ProgramTablosu } from "@/components/program-tablosu";
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
import { SiteFooter } from "@/components/site-footer";
import { SiraGerekli } from "./sira-gerekli";
// listeOlustur aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort
// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA
// dolmalı ki kredi iadesi her zaman çalışabilsin.
export const maxDuration = 120;
export const metadata: Metadata = {
title: "Tercih Listeni Kur",
// Param uzayı sonsuz + içerik sıralamaya göre kişisel: indexlenmez ama
@@ -38,7 +31,6 @@ export default async function SonucPage({
sira?: string;
tur?: string;
sihirbaz?: string;
acildi?: string;
hazir?: string;
}>;
}) {
@@ -58,34 +50,25 @@ export default async function SonucPage({
);
}
// Sihirbaz için: kullanıcı durumu, kayıtlı rapor ve dinamik facet'ler.
// Rapor sorgusu yalnızca session.user.id'ye bağlı — getCurrentUser'ı
// beklemeden paralel çalışır (seri DB zinciri kurma).
// Sihirbaz için: kullanıcının kayıtlı raporu var mı? Listenin kendisi
// /listem'de yaşar; burada yalnızca CTA'nın hangi hâlde render edileceği
// için varlık bilgisi gerekir.
const session = await getSession();
const [user, raporSatiri] = session
? await Promise.all([
getCurrentUser(),
appDb.query.reports.findFirst({
where: eq(schema.reports.userId, session.user.id),
}),
])
: [null, null];
const raporSatiri = session
? await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, session.user.id),
columns: { result: true },
})
: null;
const facetler = rankWindowFacets(sira, turKey);
// Yapay Zeka'sız ham liste: yalnızca sıralamayla ulaşılabilen programlar (dilimli)
const hamSonuclar = searchByRank(sira, turKey, { limitPerBucket: 20 });
// Paketsize giden rapor sunucuda maskelenir: blur yalnızca CSS olduğundan
// kilitli satırların gerçek içeriği client'a hiç inmez.
const hasPaket = Boolean(user?.hasPaket);
const mevcutRapor: MevcutRapor | null = raporSatiri?.result
? {
rapor: hasPaket
? (raporSatiri.result as RaporSonuc)
: raporMaskele(raporSatiri.result as RaporSonuc),
params: raporSatiri.params as RaporParams,
revisionCount: raporSatiri.revisionCount,
}
: null;
// Girişsiz tadımlık (İP-B): SSR'da kovaya uyan "genel" satır seçilir;
// sihirbaz kategorisi client'ta okunup /api/tadimlik ile kişiselleştirilir.
// Girişli kullanıcı gerçek ürünü zaten görebildiği için tadımlık almaz.
const tadimlik = session ? null : await tadimlikSec(sira, turKey);
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
@@ -99,18 +82,21 @@ export default async function SonucPage({
gövdesi; puan türü navbar'daki sıralama rozetinden değiştirilir */}
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
{/* Sihirbaz akışı (CTA + modal + Yapay Zeka listesi) — blurlu Yapay Zeka paneli
sayfanın en altında yaşar */}
{/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
Havuzda içerik yoksa bileşen hiç render edilmez. */}
{!session ? (
<TadimlikSatiri sira={sira} tur={turKey} ilk={tadimlik} />
) : null}
{/* Sihirbaz akışı (CTA + modal) — üretim ve liste /listem'de yaşar,
burada yalnızca funnel kartları render edilir */}
<SihirbazBolumu
sira={sira}
tur={turKey}
facetler={facetler}
girisliMi={Boolean(session)}
hasPaket={hasPaket}
kredi={user?.creditBalance ?? 0}
mevcutRapor={mevcutRapor}
raporVar={Boolean(raporSatiri?.result)}
otomatikAc={params.sihirbaz === "1"}
acilis={params.acildi === "1"}
secimlerHazir={params.hazir === "1"}
/>

View File

@@ -3,29 +3,26 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight, X } from "lucide-react";
import { ArrowRight } from "lucide-react";
import { animate } from "motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import { useReducedMotion } from "motion/react";
import { Button } from "@/components/ui/button";
import { ViewportPortal } from "@/components/viewport-portal";
import { PixelField } from "@/components/pixel-decor";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { olay } from "@/lib/analitik";
import type { SihirbazFacetleri } from "@/lib/db";
import {
SIHIRBAZ_AC_EVENT,
SIHIRBAZ_STORAGE_KEY,
sihirbazDogrula,
sonucHref,
tercihProfiliSil,
tercihProfiliYaz,
type SihirbazSecimleri,
} from "@/lib/sihirbaz";
import { olay, siraKovasi } from "@/lib/analitik";
import { SihirbazModal } from "./sihirbaz-modal";
import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli";
import { listeOlustur, listeRevize } from "./actions";
const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali";
// Üretim /listem'de koşar (bkz. listem/liste-uretici.tsx); sihirbaz
// tamamlanınca seçimler storage'a yazılıp kullanıcı buraya yönlendirilir.
const LISTEM_URET_HREF = "/listem?uret=1";
// SSR'da false, client'ta true — storage'a ancak mount sonrası dokunuruz
const bosAbone = () => () => {};
@@ -43,42 +40,35 @@ type BekleyenSecim = {
/**
* Sonuç sayfasında sihirbaz akışını yöneten client orkestratör:
* - Sayfa ortasındaki büyük CTA + sabit alt çağrı kartı (rapor yokken)
* - Sayfa ortasındaki büyük CTA (rapor yokken)
* - Adımları toplayan modal
* - Üretim/önizleme/liste paneli (sayfa gövdesinde)
* Akış: adımlar → kredi ile üretim → paketsizse progressive-blur önizleme,
* paketliyse tam liste. Girişsiz kullanıcı hero'dan seçimleri tamamlanmış
* gelirse önce ücretsiz tabloyu görür; giriş CTA'sı "hazır seçimlerin
* kaydedilmesi" olarak panelde sunulur, girişten dönüşte üretim otomatik
* başlar. Modal içinden ilerleyen girişsiz kullanıcı ise butonun söylediği
* gibi doğrudan girişe gider.
* Liste bu sayfada üretilmez ve gösterilmez: sihirbaz tamamlanınca seçimler
* storage'a yazılır ve kullanıcı /listem'e yönlendirilir; üretim animasyonu,
* kredi/hata durumları ve listenin kendisi orada yaşar. Girişsiz kullanıcı
* hero'dan seçimleri tamamlanmış gelirse önce ücretsiz tabloyu görür; giriş
* CTA'sı "hazır seçimlerin kaydedilmesi" olarak panelde sunulur, girişten
* dönüş doğrudan /listem'e düşer ve üretim orada başlar.
*/
export function SihirbazBolumu({
sira,
tur,
facetler,
girisliMi,
hasPaket,
kredi: baslangicKredi,
mevcutRapor,
raporVar,
otomatikAc,
acilis,
secimlerHazir,
}: {
sira: number;
tur: string;
facetler: SihirbazFacetleri;
girisliMi: boolean;
hasPaket: boolean;
kredi: number;
mevcutRapor: MevcutRapor | null;
/** Kullanıcının kayıtlı bir Yapay Zeka listesi var mı (listenin kendisi /listem'de) */
raporVar: boolean;
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
otomatikAc?: boolean;
/** ?acildi=1 (ödeme dönüşü): blur çözülme animasyonuyla aç */
acilis?: boolean;
/** ?hazir=1 (hero sihirbazından girişsiz geliş): seçimler storage'da,
panel giriş CTA'sına döner. URL'den gelir ki SSR'da doğru panel
render edilsin — mount sonrası anahtar değişimi animasyonu donduruyor. */
render edilsin. */
secimlerHazir?: boolean;
}) {
const router = useRouter();
@@ -87,36 +77,19 @@ export function SihirbazBolumu({
const [elleAcik, setElleAcik] = useState(false);
const [elleKapandi, setElleKapandi] = useState(false);
const [kartGizle, setKartGizle] = useState(false);
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
// ?sihirbaz=1 fallback modalı storage okunmadan açılmasın: hero'dan gelen
// kayıtlı seçimler varken modal bir anlığına açılıp kapanıyordu.
const [depoOkundu, setDepoOkundu] = useState(false);
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
const [kredi, setKredi] = useState(baslangicKredi);
const [paketli, setPaketli] = useState(hasPaket);
const [durum, setDurum] = useState<PanelDurum>("bos");
// Adımlarda yapılan son seçimler (hata sonrası modala dönüşte prefill)
const [sonSecimler, setSonSecimler] = useState<SihirbazSecimleri | null>(
null,
);
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
const requestIdRef = useRef<string | null>(null);
// Revizyon için aynı desen (bkz. listem/revizyon-kutusu.tsx)
const revizyonIdRef = useRef<string | null>(null);
const otomatikBasladi = useRef(false);
const bolumRef = useRef<HTMLDivElement>(null);
// Funnel CTA'ları (banner olayı dahil) tıklama anındaki bekleyen seçimi
// görsün diye ref'te tutulur; olay dinleyicisi bir kez bağlanır.
const bekleyenRef = useRef<BekleyenSecim | null>(null);
// Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku
// Mount sonrası storage'dan bekleyen seçimleri oku
useEffect(() => {
try {
const kaliciKapali = Boolean(sessionStorage.getItem(KAPATMA_KEY));
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
let yeniBekleyen: BekleyenSecim | null = null;
if (ham) {
@@ -133,9 +106,8 @@ export function SihirbazBolumu({
};
}
}
// eslint-disable-next-line react-hooks/set-state-in-effect
if (kaliciKapali) setKartGizle(true);
if (yeniBekleyen) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setBekleyen(yeniBekleyen);
bekleyenRef.current = yeniBekleyen;
}
@@ -151,19 +123,6 @@ export function SihirbazBolumu({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* Bölüm ekran dışındaysa (kullanıcı sihirbazı üstteki banner'dan ya da
* sabit alt karttan açmış olabilir) modal hâlâ perdeyi kaplarken kaydırırız;
* böylece Motion ile giren hedef panel görünür alanda kalır.
*/
function bolumuGorunureAl() {
const el = bolumRef.current;
if (!el) return;
const kutu = el.getBoundingClientRect();
if (kutu.top >= 0 && kutu.bottom <= window.innerHeight) return;
el.scrollIntoView({ block: "center", behavior: "instant" });
}
async function modalCikisiniOynat() {
if (hareketiAzalt) return;
@@ -202,85 +161,26 @@ export function SihirbazBolumu({
await modalAnimasyonu;
}
async function uret(secimler: SihirbazSecimleri) {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
bolumuGorunureAl();
/**
* Sihirbaz tamamlandı: seçimleri storage'a yaz ve üretimin koştuğu
* /listem'e geç. Girişsiz kullanıcı önce girişe uğrar; callback doğrudan
* /listem'e düştüğünden üretim dönüşte orada otomatik başlar.
*/
async function ilerle(secimler: SihirbazSecimleri) {
tercihProfiliYaz({ sira, tur, secimler });
await modalCikisiniOynat();
setElleAcik(false);
setDurum("uretiliyor");
try {
const sonuc = await listeOlustur({
sira,
tur,
secimler,
requestId: requestIdRef.current,
});
if (sonuc.ok) {
requestIdRef.current = null;
olay("liste_olusturuldu", { tur, sira_kovasi: siraKovasi(sira) });
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
setPaketli(sonuc.hasPaket);
tercihProfiliSil();
// Yapay Zeka çıktısı bu sayfada gösterilmez: üretim animasyonu açık kalır,
// liste (paketsizse paywall'lı hâliyle) /listem'de karşılar.
router.push("/listem");
// Header'daki kredi pill'i layout'ta server-render edilir; push layout'u
// yeniden çizmediği için düşen bakiye refresh ile güncellenir.
router.refresh();
return;
}
// Sunucudan cevap geldiyse harcama ya hiç olmadı ya iade edildi;
// sonraki deneme taze requestId ile başlar (aynı id ledger'daki UNIQUE'e
// takılıp DUPLICATE çıkmazına giriyordu). requestId yalnızca cevap
// alınamayan network hatasında (catch) korunur — orada çifte harcama
// riski gerçek.
requestIdRef.current = null;
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
setDurum("kredi-yetersiz");
} else if (sonuc.code === "AUTH") {
secimleriKaydetVeGirise(secimler);
} else {
toast.error(sonuc.error);
modalaDon();
}
} catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
modalaDon();
}
}
/** Hata sonrası panelden modala dönüş. */
function modalaDon() {
setDurum("bos");
setElleAcik(true);
}
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
tercihProfiliYaz({ sira, tur, secimler });
const geri = sonucHref({ sira, tur }, { sihirbaz: "1" });
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
}
async function ilerle(secimler: SihirbazSecimleri) {
setSonSecimler(secimler);
if (!girisliMi) {
await modalCikisiniOynat();
secimleriKaydetVeGirise(secimler);
router.push(`/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`);
return;
}
await uret(secimler);
router.push(LISTEM_URET_HREF);
}
/**
* Funnel CTA'larının (banner, büyük kart, sabit alt kart) ortak girişi:
* Funnel CTA'larının (banner, büyük kart) ortak girişi:
* adımlar zaten tamamlanıp kaydedildiyse modal açılmaz — girişli
* kullanıcıda üretim doğrudan başlar, girişsiz kullanıcı girişe gider.
* kullanıcı doğrudan /listem'e gider, girişsiz kullanıcı girişe gider.
* Kayıtlı seçim yoksa (ya da başka sıra/türe aitse) modal açılır.
*/
function sihirbaziBaslat() {
@@ -293,28 +193,28 @@ export function SihirbazBolumu({
setElleKapandi(false);
}
// Giriş dönüşü: storage'daki eşleşen seçimlerle otomatik üretime başla
// (bir kez, modal açmadan — üretim sayfa gövdesinde oynar).
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && !rapor);
// Giriş dönüşü (?sihirbaz=1) storage'da eşleşen seçimlerle geldiyse modal
// ılmaz: seçimler hazır, üretim /listem'de başlasın (bir kez).
const otomatikGonder = Boolean(
otomatikAc && bekleyen?.eslesir && girisliMi && !raporVar,
);
useEffect(() => {
if (otomatikUret && bekleyen && !otomatikBasladi.current) {
if (otomatikGonder && !otomatikBasladi.current) {
otomatikBasladi.current = true;
setSonSecimler(bekleyen.secimler);
void uret(bekleyen.secimler);
// replace: geri tuşu bu sayfaya dönünce tekrar /listem'e sekmesin
router.replace(LISTEM_URET_HREF);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [otomatikUret]);
}, [otomatikGonder]);
// ?sihirbaz=1: otomatik üretim devreye girmediyse (seçim kaybolduysa/
// ?sihirbaz=1: otomatik yönlendirme devreye girmediyse (seçim kaybolduysa/
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin. Karar
// storage okunmadan verilmez: kayıtlı seçimlerle gelen kullanıcıya modal
// hiç gösterilmez, üretim doğrudan başlar.
// hiç gösterilmez, /listem'e geçilir.
const acik =
!elleKapandi &&
(elleAcik ||
Boolean(
otomatikAc && depoOkundu && !otomatikUret && !rapor && durum === "bos",
));
Boolean(otomatikAc && depoOkundu && !otomatikGonder && !raporVar));
function acikDegisti(v: boolean) {
if (v) {
@@ -326,245 +226,113 @@ export function SihirbazBolumu({
}
}
const panelde = Boolean(rapor) || durum !== "bos";
// Değer önce: sihirbazı bitirmiş ama girişsiz kullanıcı ücretsiz tabloyu
// zaten görüyor; giriş burada bariyer değil, hazır seçimlerin (ödülün)
// kaydedilmesi olarak sunulur. Girişten dönüşte otomatik üretim başlar.
// Sinyal URL'den (?hazir=1) gelir; storage'a bakılsaydı panel anahtarı
// mount sonrası değişir ve AnimatePresence geçişi donardı.
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !rapor);
const girisUrl = `/giris?callback=${encodeURIComponent(
sonucHref({ sira, tur }, { sihirbaz: "1" }),
)}`;
const panelAnahtari =
durum !== "bos"
? durum
: rapor
? "rapor-hazir"
: girisBekliyor
? "giris-cta"
: "sihirbaz-cta";
// kaydedilmesi olarak sunulur. Girişten dönüşte üretim /listem'de başlar.
// Sinyal URL'den (?hazir=1) gelir ki SSR'da doğru panel render edilsin.
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !raporVar);
const girisUrl = `/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`;
// Kapı 1 ölçümü: panel yalnızca girisBekliyor dalında render edilir; ref
// başka dalda bağlanmadığından observer da yalnızca o durumda kurulur.
const hazirPanelRef = useGorunumOlayi<HTMLElement>("hazir_panel_goruntulendi");
return (
<>
{/* Yapay Zeka çıktısı (rapor listesi/haritası) bu sayfada render edilmez;
panel yalnızca üretim ve kredi-yetersiz gibi geçici funnel
durumlarını gösterir, listenin kendisi /listem'de yaşar. */}
<div ref={bolumRef} className="relative">
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={panelAnahtari}
initial={
hareketiAzalt
? { opacity: 0 }
: {
opacity: 0,
transform: "translate3d(0, 8px, 0)",
}
}
animate={{
opacity: 1,
transform: "translate3d(0, 0, 0)",
transition: hareketiAzalt
? { duration: 0.12, ease: "easeOut" }
: {
duration: 0.28,
ease: [0.23, 1, 0.32, 1],
},
}}
exit={
hareketiAzalt
? {
opacity: 0,
transition: { duration: 0.08, ease: "easeOut" },
}
: {
opacity: 0,
transform: "translate3d(0, -4px, 0)",
transition: {
duration: 0.14,
ease: [0.4, 0, 0.7, 0.2],
},
}
}
{/* Yapay Zeka çıktısı (üretim animasyonu dahil) bu sayfada render
edilmez; panel yalnızca funnel CTA'larını gösterir, üretim ve
listenin kendisi /listem'de yaşar. */}
{raporVar ? (
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
24 tercihlik listen hazır
</h2>
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
seni bekliyor.
</p>
<Button
asChild
size="lg"
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
{durum !== "bos" ? (
<ListePaneli
durum={durum}
rapor={null}
hasPaket={paketli}
kredi={kredi}
sira={sira}
acilis={acilis}
onRevize={async (feedback) => {
if (!revizyonIdRef.current) {
revizyonIdRef.current = crypto.randomUUID();
}
const sonuc = await listeRevize({
feedback,
requestId: revizyonIdRef.current,
});
revizyonIdRef.current = null;
if (sonuc.ok) {
olay("liste_revize", { sayfa: "sonuc" });
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
toast.success("Listen yeniden kuruldu.");
} else {
toast.error(sonuc.error);
}
return sonuc.ok;
}}
/>
) : rapor ? (
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
24 tercihlik listen hazır
</h2>
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
seni bekliyor.
</p>
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/listem">Listeni gör</Link>
</Button>
</section>
) : girisBekliyor ? (
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={13} />
</div>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
Seçimlerin hazır Yapay Zeka listen bir adım uzakta
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
tercihlik Yapay Zeka listeni de kurabilirsin: ücretsiz giriş yap,
seçimlerin kayıtlı listen otomatik kurulsun (üretim 3
kredi, kalan 2 kredinle danışmana soru sorarsın).
</p>
<Button
asChild
size="lg"
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
<Link href={girisUrl}>
5 deneme kredinle Yapay Zeka listeni kur giriş yap
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="relative mt-3 text-xs text-white/70">
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
</p>
</section>
) : (
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={19} />
</div>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
24 tercihi tek tek elemek yerine, yapay zekâ kursun
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Tablodan elle eklemek her zaman serbest. Ama üç adımda ilgi
alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli
ve garanti dilimlerine dağıtılmış 24 tercihi her satırı
yerleşme riski ve gerekçesiyle senin yerine dizer.
</p>
<Button
size="lg"
onClick={sihirbaziBaslat}
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
Yapay Zeka listemi kur
<ArrowRight className="size-4" aria-hidden />
</Button>
</section>
)}
</motion.div>
</AnimatePresence>
</div>
{/* Sabit alt çağrı kartı — liste/panel yokken ve kart kapatılmadıysa */}
<ViewportPortal>
{monteEdildi && !panelde && !kartGizle && !acik ? (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
<div className="pointer-events-auto mx-auto flex max-w-xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl">
<div className="flex-1">
<p className="font-heading text-sm font-bold text-slate-900">
{girisBekliyor
? "Seçimlerin kayıtlı — Yapay Zeka listen hazır bekliyor"
: "Tek tek ekleme, yapay zekâ kursun"}
</p>
<p className="text-xs text-slate-500">
{girisBekliyor
? "Ücretsiz giriş yap, 5 deneme kredinle listen otomatik kurulsun."
: "Üç adımda seçimlerini söyle; risk analizli, gerekçeli 24 tercih hazır."}
</p>
</div>
{girisBekliyor ? (
<Button
asChild
size="sm"
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={girisUrl}>Giriş yap</Link>
</Button>
) : (
<Button
size="sm"
onClick={sihirbaziBaslat}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Başla
</Button>
)}
<button
type="button"
aria-label="Kapat"
onClick={() => {
setKartGizle(true);
try {
sessionStorage.setItem(KAPATMA_KEY, "1");
} catch {}
}}
className="cursor-pointer rounded-full p-1 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
>
<X className="size-4" aria-hidden />
</button>
</div>
<Link href="/listem">Listeni gör</Link>
</Button>
</section>
) : girisBekliyor ? (
<section
ref={hazirPanelRef}
className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12"
>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={13} />
</div>
) : null}
</ViewportPortal>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
Seçimlerin hazır Yapay Zeka listen bir adım uzakta
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
tercihlik Yapay Zeka listeni de kurabilirsin: ücretsiz giriş yap,
seçimlerin kayıtlı listen otomatik kurulsun (üretim 3
kredi, kalan 2 kredinle danışmana soru sorarsın).
</p>
<Button
asChild
size="lg"
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
<Link
href={girisUrl}
onClick={() =>
olay("giris_cta_tiklandi", { kaynak: "hazir_panel" })
}
>
5 deneme kredinle Yapay Zeka listeni kur giriş yap
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="relative mt-3 text-xs text-white/70">
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
</p>
</section>
) : (
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={19} />
</div>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
24 tercihi tek tek elemek yerine, yapay zekâ kursun
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Tablodan elle eklemek her zaman serbest. Ama üç adımda ilgi
alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli
ve garanti dilimlerine dağıtılmış 24 tercihi her satırı
yerleşme riski ve gerekçesiyle senin yerine dizer.
</p>
<Button
size="lg"
onClick={sihirbaziBaslat}
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
Yapay Zeka listemi kur
<ArrowRight className="size-4" aria-hidden />
</Button>
</section>
)}
{/* Üretim başladığında modalı kaldır; yeni panelin geçişini Motion
üstlenir ve odağı panel içeriğine taşır. */}
{monteEdildi && durum === "bos" ? (
{monteEdildi ? (
<SihirbazModal
acik={acik}
onAcikDegisti={acikDegisti}
sira={sira}
tur={tur}
facetler={facetler}
baslangicSecimler={
sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
}
baslangicSecimler={bekleyen?.eslesir ? bekleyen.secimler : null}
sonButonEtiketi={
girisliMi ? "Yapay Zeka listemi kur (3 kredi)" : "Giriş yap ve listeni gör"
}

View File

@@ -12,7 +12,7 @@ import type { SihirbazSecimleri } from "@/lib/sihirbaz";
/**
* Sihirbaz modalı artık yalnızca adımları toplar; üretim, önizleme ve liste
* sonuç sayfası gövdesinde yaşar (bkz. liste-paneli.tsx). Akış yönetimi
* /listem'de yaşar (bkz. listem/liste-uretici.tsx). Akış yönetimi
* sihirbaz-cagri-karti.tsx'teki SihirbazBolumu'ndadır.
*/
export function SihirbazModal({

View File

@@ -1,120 +0,0 @@
"use client";
// Yapay Zeka danışman sohbeti — sayfa altında yüzen buton + intercom tarzı popup.
// Motion dili: panel tetikleyicisinden doğar (transform-origin sağ-alt),
// spring ile açılır; çıkış girişten hızlı. Reduced motion'da cross-fade.
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { MessageCircleMore, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Skeleton } from "@/components/ui/skeleton";
import { ViewportPortal } from "@/components/viewport-portal";
function SohbetSkeleton() {
return (
<div
aria-busy="true"
aria-live="polite"
className="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl"
>
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<Skeleton className="h-5 w-36" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
<div className="flex flex-1 flex-col items-center gap-4 px-4 py-10">
<Skeleton className="size-7 rounded-full" />
<Skeleton className="h-5 w-56 max-w-full" />
<div className="w-full max-w-md space-y-2">
<Skeleton className="mx-auto h-3.5 w-full" />
<Skeleton className="mx-auto h-3.5 w-4/5" />
</div>
<div className="flex max-w-lg flex-wrap justify-center gap-2">
{[112, 148, 96, 132].map((w, i) => (
<Skeleton
key={i}
className="h-7 rounded-full"
style={{ width: w }}
/>
))}
</div>
</div>
<div className="border-t border-slate-100 p-3">
<Skeleton className="h-24 w-full rounded-2xl" />
</div>
</div>
);
}
// Sohbet istemcisi (markdown zinciriyle birlikte) ağır; popup ilk açıldığında
// yüklensin — /sonuc ilk yükünü şişirmesin.
const SohbetClient = dynamic(
() => import("@/app/listem/sohbet-client").then((m) => m.SohbetClient),
{
ssr: false,
loading: () => <SohbetSkeleton />,
},
);
export function SohbetPopup({ kredi }: { kredi: number }) {
const [acik, setAcik] = useState(false);
const azMotion = useReducedMotion();
useEffect(() => {
if (!acik) return;
const dinle = (e: KeyboardEvent) => {
if (e.key === "Escape") setAcik(false);
};
window.addEventListener("keydown", dinle);
return () => window.removeEventListener("keydown", dinle);
}, [acik]);
return (
<ViewportPortal>
<AnimatePresence>
{acik ? (
<motion.div
key="sohbet"
initial={
azMotion ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.95 }
}
animate={azMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={
azMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
opacity: 0,
y: 16,
scale: 0.97,
transition: { duration: 0.18, ease: [0.23, 1, 0.32, 1] },
}
}
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
style={{ transformOrigin: "bottom right" }}
className="fixed bottom-24 right-4 z-50 h-[min(72dvh,560px)] w-[min(100vw-2rem,420px)] print:hidden"
>
<SohbetClient
baslangicKredi={kredi}
className="h-full shadow-2xl"
/>
</motion.div>
) : null}
</AnimatePresence>
{/* Yüzen tetikleyici — basışta anında küçülür (pointer-down feedback) */}
<button
type="button"
onClick={() => setAcik((v) => !v)}
aria-expanded={acik}
className="fixed bottom-6 right-4 z-50 flex h-13 cursor-pointer items-center gap-2 rounded-full bg-slate-900 py-3 pl-4 pr-5 font-heading text-sm font-bold text-white shadow-xl transition-[transform,background-color] duration-150 hover:bg-slate-700 active:scale-[0.95] print:hidden"
>
{acik ? (
<X className="size-5" aria-hidden />
) : (
<MessageCircleMore className="size-5" aria-hidden />
)}
{acik ? "Kapat" : "Danışmana sor"}
</button>
</ViewportPortal>
);
}

View File

@@ -0,0 +1,173 @@
"use client";
// Girişsiz yapay zekâ tadımlığı (PRD İP-B): /sonuc'ta, sıra kovası + (varsa)
// sihirbaz alan tercihine uyan TEK örnek tercih satırı — gerçek rapor
// satırının detay bloğuyla (RaporSatirDetayi) birebir aynı formatta.
// İçerik batch üretilmiş havuzdan gelir; bu bileşen LLM'e istek attırmaz.
// SSR "genel" satırı render eder (prop), mount sonrası sihirbaz kategorisi
// varsa /api/tadimlik'ten kategoriye özel satır çekilip yerinde değiştirilir
// (seçim sunucuda kalır). Havuzda içerik yoksa hiç render edilmez.
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { UniLogo } from "@/components/uni-logo";
import { SectionEyebrow } from "@/components/pixel-decor";
import { RaporSatirDetayi } from "@/components/rapor-listesi";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
programEtkinSira,
raporSiraSerisi,
} from "@/components/program-liste-verileri";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { olay, siraKovasi } from "@/lib/analitik";
import { dilimdenRisk, riskHesapla } from "@/lib/risk";
import { turkishSlugify } from "@/lib/slug";
import {
PROFIL_DEGISTI_EVENT,
sonucHref,
tercihProfiliOku,
} from "@/lib/sihirbaz";
import type { TadimlikSatir } from "@/lib/tadimlik-havuzu";
const GENEL = "genel";
export function TadimlikSatiri({
sira,
tur,
ilk,
}: {
sira: number;
tur: string;
/** SSR'da seçilmiş "genel" satır; havuz boşsa null. */
ilk: TadimlikSatir | null;
}) {
const [satir, setSatir] = useState<TadimlikSatir | null>(ilk);
const yuklenenSlug = useRef(GENEL);
// Sihirbaz kategorisi yalnızca localStorage'da yaşar; mount'ta ve profil
// değiştikçe okunur, kategoriye özel satır sunucudan çekilir.
useEffect(() => {
const guncelle = () => {
const profil = tercihProfiliOku();
const kategori = profil?.secimler.kategoriler[0];
const slug = kategori ? turkishSlugify(kategori) : GENEL;
if (slug === yuklenenSlug.current) return;
yuklenenSlug.current = slug;
if (slug === GENEL) {
setSatir(ilk);
return;
}
fetch(
`/api/tadimlik?sira=${sira}&tur=${encodeURIComponent(tur)}&kategori=${encodeURIComponent(slug)}`,
)
.then((yanit) => (yanit.ok ? yanit.json() : null))
.then((veri: { tadimlik: TadimlikSatir | null } | null) => {
// Yarış koruması: bu arada başka kategori yüklendiyse dokunma
if (yuklenenSlug.current !== slug) return;
if (veri?.tadimlik) setSatir(veri.tadimlik);
})
.catch(() => {});
};
guncelle();
window.addEventListener(PROFIL_DEGISTI_EVENT, guncelle);
return () => window.removeEventListener(PROFIL_DEGISTI_EVENT, guncelle);
}, [sira, tur, ilk]);
const gorunumRef = useGorunumOlayi<HTMLElement>("tadimlik_goruntulendi", {
kova: siraKovasi(sira),
});
if (!satir) return null;
const p = satir.program;
const risk = riskHesapla(p.efektifSira, sira) ?? dilimdenRisk(satir.dilim);
const seri = raporSiraSerisi(p.siraGecmisi);
const taban = p.efektifSira ?? programEtkinSira(seri);
const devlet = p.unitur === "DEVLET";
const girisUrl = `/giris?callback=${encodeURIComponent(sonucHref({ sira, tur }))}`;
return (
<section ref={gorunumRef} className="mt-16 min-w-0">
<div className="flex flex-col items-center text-center">
<SectionEyebrow>Yapay zekâdan örnek satır</SectionEyebrow>
<h2 className="mt-4 font-heading text-2xl font-bold sm:text-3xl">
Yapay zekâ listendeki her satırı böyle anlatır
</h2>
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Sıralamana uygun bir programın gerekçesi, yerleşme riski ve taban
trendi 24 tercihlik listenin her satırı bu formatta kurulur.
</p>
</div>
<div className="mt-6 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:p-6">
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
<UniLogo ad={p.universite} boy="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">
{p.isim}
</span>
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
{p.unitur ? (
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
devlet
? "bg-slate-100 text-slate-600"
: "bg-violet-50 text-violet-600"
}`}
>
{devlet ? "Devlet" : "Vakıf"}
</span>
) : null}
<span className="truncate">
{p.universite}
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
</span>
</span>
<span className="hidden text-right text-xs md:inline-flex">
<ProgramSiraGecmisi program={seri} />
</span>
<span className="inline-flex items-center gap-1 text-xs tabular-nums text-slate-500">
<ProgramTabanTrendi program={seri} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
</span>
</div>
<div className="mt-4 rounded-lg bg-slate-50/60 px-4 py-4">
<RaporSatirDetayi
dilim={satir.dilim}
risk={risk}
unitur={p.unitur}
gerekce={satir.gerekce}
riskNotu={satir.riskNotu}
trendOzeti={satir.trendOzeti}
/>
</div>
<div className="mt-5 flex flex-col items-start gap-3 border-t border-slate-100 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-slate-600">
Bu, 24 satırlık Yapay Zeka listenden yalnızca bir örnek.
<span className="block text-xs text-slate-500">
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
</span>
</p>
<Button
asChild
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
>
<Link
href={girisUrl}
onClick={() => olay("giris_cta_tiklandi", { kaynak: "tadimlik" })}
>
24 satırın tamamı için giriş yap 5 deneme kredin hazır
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</div>
</div>
</section>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
ArrowRight,
ChartNoAxesCombined,
@@ -76,12 +76,15 @@ const KANITLAR = [
export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const profil = useTercihProfili();
const [metinler] = useState(() => karistir(FUNNEL_METINLERI));
function sihirbazaGit() {
onSec();
if (pathname === "/sonuc") {
// Orkestratör (SihirbazBolumu) yalnızca /sonuc?sira= geçerliyken monte;
// sırasız /sonuc'ta (SiraGerekli görünümü) olay boşa gider — kapıya düş.
if (pathname === "/sonuc" && searchParams.get("sira")) {
window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT));
return;
}
@@ -89,7 +92,9 @@ export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
router.push(sonucHref(profil, { sihirbaz: "1" }));
return;
}
profilToplamaAc("sonuc");
// Yapay Zeka niyetli CTA: kapı tamamlanınca /sonuc'ta üretim akışı devam
// etmeli ("sonuc" hedefi ücretsiz tabloda bırakıyor, liste hiç kurulmuyordu).
profilToplamaAc("sihirbaz");
}
return (
@@ -134,7 +139,7 @@ export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
<Button
size="lg"
onClick={sihirbazaGit}
className="h-11 w-full shrink-0 cursor-pointer rounded-xl bg-orange-500 px-5 font-semibold text-white shadow-[0_8px_24px_rgba(249,115,22,0.28)] transition-[background-color,transform,box-shadow] duration-200 hover:bg-orange-400 hover:shadow-[0_10px_28px_rgba(249,115,22,0.36)] active:scale-[0.98] focus-visible:ring-white/70 sm:w-auto"
className="w-full shrink-0 cursor-pointer bg-orange-500 font-semibold text-white shadow-[0_8px_24px_rgba(249,115,22,0.28)] transition-[background-color,transform,box-shadow] duration-200 hover:bg-orange-400 hover:shadow-[0_10px_28px_rgba(249,115,22,0.36)] active:scale-[0.98] focus-visible:ring-white/70 sm:w-auto"
>
Yapay Zeka listemi kur
<ArrowRight className="size-4" aria-hidden />

View File

@@ -11,6 +11,7 @@ import {
ProgramTabanTrendi,
} from "@/components/program-liste-verileri";
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
import { ProgramNetleriButonu } from "@/components/program-netleri";
import { UniLogo } from "@/components/uni-logo";
import {
Table,
@@ -55,7 +56,7 @@ function SiralamaDugmesi({
aktif && yon === "artan" ? "azalan" : "artan"
} sırala`}
aria-pressed={aktif}
className="inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-md px-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-full px-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span>{etiket}</span>
<span className="flex flex-col -space-y-1" aria-hidden>
@@ -180,8 +181,8 @@ export function BolumProgramListesi({
onSirala={sirala}
/>
</TableHead>
<TableHead className="w-14 text-right">
<span className="sr-only">Listeye ekle</span>
<TableHead className="w-24 text-right">
<span className="sr-only">Netler ve listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
@@ -254,11 +255,19 @@ export function BolumProgramListesi({
</span>
</TableCell>
<TableCell className="pr-3 text-right">
<ProgramEkleButonu
program={p}
kaynak="bolum"
className="size-8 min-h-8 min-w-8"
/>
<span className="inline-flex items-center gap-1.5">
<ProgramNetleriButonu
programId={p.id}
isim={p.isim.trim()}
universite={uniAd}
className="size-8 min-h-8 min-w-8"
/>
<ProgramEkleButonu
program={p}
kaynak="bolum"
className="size-8 min-h-8 min-w-8"
/>
</span>
</TableCell>
</TableRow>
);

View File

@@ -60,7 +60,7 @@ const KANITLAR = [
*
* Profilsiz CTA adayı ana sayfaya göndermez: buton alttan bir çekmece açar,
* sıralama girişi ve sihirbaz adayın bulunduğu sayfanın bağlamında akar.
* Tasarım gerekçesi için bkz. CTA-CEKMECE-TASARIMI.md.
* Tasarım gerekçesi için bkz. docs/tasarim/cta-cekmece-tasarimi.md.
*/
export function CtaSiraForm({ baslik }: { baslik?: string }) {
const profil = useTercihProfili();
@@ -153,7 +153,7 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
</p>
<Link
href={sonucHref(profil)}
className="mt-4 inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-orange-500 px-6 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
className="mt-4 inline-flex h-12 items-center justify-center gap-2 rounded-full bg-orange-500 px-7 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 />

View File

@@ -3,14 +3,17 @@
import { usePathname } from "next/navigation";
// Site kromunun (banner, header, üst/alt blur) gizleneceği rotalar
// (tam eşleşme veya alt yol)
// (tam eşleşme veya alt yol). KaydetBanner da aynı listeye uyar.
const HIDDEN_PREFIXES = ["/giris"];
export function kromGizliMi(pathname: string) {
return HIDDEN_PREFIXES.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
}
export function HeaderGate({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const gizli = HIDDEN_PREFIXES.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
if (gizli) return null;
if (kromGizliMi(pathname)) return null;
return <>{children}</>;
}

View File

@@ -22,7 +22,7 @@ export function HeroFocusButton() {
type="button"
size="lg"
onClick={handleClick}
className="mt-8 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Ücretsiz dene
</Button>
@@ -47,7 +47,7 @@ export function KapanisCta() {
<Button
asChild
size="lg"
className="mt-8 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={sonucHref(profil)}>
Üniversitelere göz at

View File

@@ -116,7 +116,7 @@ export function HeroForm() {
<Button
asChild
size="lg"
className="h-12 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
className="w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
>
<Link href={sonucHref(kayitliProfil)}>
Üniversitelere göz at
@@ -140,7 +140,9 @@ export function HeroForm() {
}
return (
<div className="flex w-full max-w-md flex-col items-center gap-3">
// max-w-xl: yatay dizilimde placeholder'ın tamamı inputa sığsın diye
// (md'de buton geniş olduğundan inputa ~200px kalıyor, metin kesiliyordu).
<div className="flex w-full max-w-xl flex-col items-center gap-3">
<div
role="radiogroup"
aria-label="Puan türü"
@@ -190,9 +192,9 @@ export function HeroForm() {
type="submit"
size="lg"
disabled={yukleniyor}
className="h-12 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
{yukleniyor ? "Hazırlanıyor…" : "Yapay Zeka listemi kur"}
Yapay Zeka listemi kur
<ArrowRight className="size-4" aria-hidden />
</Button>
</form>

View File

@@ -87,7 +87,9 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
const [acik, setAcik] = useState(false);
function siralamaGirVeyaSonucaGit() {
const { href } = sonucaGitIste();
// Navbar CTA'sı "Sıralamanı gir" dediği için kapı tanıtım adımını
// atlayıp doğrudan başarı sıralaması adımıyla açılır.
const { href } = sonucaGitIste({ dogrudanSira: true });
if (href) router.push(href);
}
const [sorgu, setSorgu] = useState("");

View File

@@ -0,0 +1,87 @@
"use client";
// "Listen yalnızca bu tarayıcıda" satırları için cihaz ikonları.
//
// - IsletimSistemiLogosu: kullanıcının şu an kullandığı işletim sisteminin
// logosu (Apple / Windows / Android). UA yalnızca client'ta okunur; ilk
// boyada nötr Monitor ikonu basılır ki SSR/hydration uyuşmazlığı olmasın.
// iPad "Mac" olarak görünebilir ama ikisi de Apple logosuna düştüğü için
// sorun değil; Linux ve bilinmeyenler Monitor'da kalır.
// - CihazCifti: "her cihazdan eriş" vurgusu için telefon + bilgisayar ikonu.
import { useEffect, useState } from "react";
import { Monitor, Smartphone } from "lucide-react";
type Isletim = "apple" | "windows" | "android" | null;
function isletimSistemi(): Isletim {
const ua = navigator.userAgent;
if (/iPhone|iPad|iPod|Mac/i.test(ua)) return "apple";
if (/Android/i.test(ua)) return "android";
if (/Win/i.test(ua)) return "windows";
return null;
}
function AppleLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={className}
aria-hidden
>
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
</svg>
);
}
function WindowsLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={className}
aria-hidden
>
<path d="M3 3h8.6v8.6H3zM12.4 3H21v8.6h-8.6zM3 12.4h8.6V21H3zM12.4 12.4H21V21h-8.6z" />
</svg>
);
}
function AndroidLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={className}
aria-hidden
>
<path d="M17.523 15.3414c-.5511 0-.9993-.4486-.9993-.9997s.4482-.9993.9993-.9993c.5511 0 .9993.4482.9993.9993.0001.5511-.4482.9997-.9993.9997m-11.046 0c-.5511 0-.9993-.4486-.9993-.9997s.4482-.9993.9993-.9993c.5511 0 .9993.4482.9993.9993 0 .5511-.4482.9997-.9993.9997m11.4045-6.02l1.9973-3.4592a.416.416 0 0 0-.1521-.5676.416.416 0 0 0-.5676.1521l-2.0223 3.503C15.5902 8.2439 13.8533 7.8508 12 7.8508s-3.5902.3931-5.1367 1.0989L4.841 5.4467a.4161.4161 0 0 0-.5677-.1521.4157.4157 0 0 0-.1521.5676l1.9973 3.4592C2.6889 11.1867.3432 14.6589 0 18.761h24c-.3435-4.1021-2.6892-7.5743-6.1185-9.4396" />
</svg>
);
}
export function IsletimSistemiLogosu({ className }: { className?: string }) {
const [os, setOs] = useState<Isletim>(null);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setOs(isletimSistemi());
}, []);
if (os === "apple") return <AppleLogo className={className} />;
if (os === "windows") return <WindowsLogo className={className} />;
if (os === "android") return <AndroidLogo className={className} />;
return <Monitor className={className} aria-hidden />;
}
export function CihazCifti({ className }: { className?: string }) {
return (
<span
className={`inline-flex items-center gap-1 ${className ?? ""}`}
aria-hidden
>
<Smartphone className="size-3.5" />
<Monitor className="size-3.5" />
</span>
);
}

View File

@@ -0,0 +1,63 @@
// Manuel liste kaydının saf doğrulama katmanı. store.ts ("use client")
// buradan re-export eder; kayitli-liste-actions.ts ("use server") sunucuda
// aynı süzgeci kullanır — client/sunucu iki ayrı doğrulama yazılmaz.
import type { RiskSeviyesi } from "@/lib/risk";
export const MANUEL_LISTE_MAX = 24;
export type ManuelKaynak = "sonuc" | "bolum" | "universite";
export type ManuelTercih = {
/** yokatlas program id'si */
id: string;
isim: string;
universite: string;
il: string | null;
unitur: string | null;
tur: string | null;
efektifSira: number | null;
/** Ekleme / profil anındaki aday sıralamasına göre risk */
risk: RiskSeviyesi | null;
kaynak?: ManuelKaynak;
};
/** Güvenilmeyen JSON'u (localStorage veya sunucu kaydı) listeye süzer. */
export function dogrula(ham: unknown): ManuelTercih[] {
if (!Array.isArray(ham)) return [];
const sonuc: ManuelTercih[] = [];
for (const t of ham) {
if (typeof t !== "object" || t === null) continue;
const kayit = t as Record<string, unknown>;
if (typeof kayit.id !== "string" || typeof kayit.isim !== "string") continue;
const risk: RiskSeviyesi | null =
kayit.risk === "guvenli" ||
kayit.risk === "az-riskli" ||
kayit.risk === "riskli"
? kayit.risk
: null;
const kaynak: ManuelKaynak | undefined =
kayit.kaynak === "sonuc" ||
kayit.kaynak === "bolum" ||
kayit.kaynak === "universite"
? kayit.kaynak
: undefined;
sonuc.push({
id: kayit.id,
isim: kayit.isim,
universite: typeof kayit.universite === "string" ? kayit.universite : "",
il: typeof kayit.il === "string" ? kayit.il : null,
unitur: typeof kayit.unitur === "string" ? kayit.unitur : null,
tur: typeof kayit.tur === "string" ? kayit.tur : null,
efektifSira:
typeof kayit.efektifSira === "number" &&
Number.isFinite(kayit.efektifSira)
? kayit.efektifSira
: null,
risk,
kaynak,
});
if (sonuc.length >= MANUEL_LISTE_MAX) break;
}
return sonuc;
}

View File

@@ -0,0 +1,21 @@
"use client";
// Mevcut sayfaya (query dahil) geri dönen /giris URL'i. Render'ı saf tutmak
// için window.location yalnızca effect'te okunur; ilk render callback'siz
// /giris ile başlar (banner/çekmece zaten mount sonrası görünür).
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
export function useGirisUrl(): string {
const pathname = usePathname();
const [url, setUrl] = useState("/giris");
useEffect(() => {
try {
const hedef = `${window.location.pathname}${window.location.search}`;
// eslint-disable-next-line react-hooks/set-state-in-effect
setUrl(`/giris?callback=${encodeURIComponent(hedef)}`);
} catch {}
}, [pathname]);
return url;
}

View File

@@ -0,0 +1,11 @@
"use client";
// Banner yalnızca girişsiz + 3 seçimli kullanıcıda görünür; baseline
// bundle'a girmesin diye ssr'siz dynamic import (liste-cekmecesi-lazy deseni).
import dynamic from "next/dynamic";
export const KaydetBannerLazy = dynamic(
() => import("./kaydet-banner").then((m) => m.KaydetBanner),
{ ssr: false },
);

View File

@@ -0,0 +1,110 @@
"use client";
// C2 yumuşak banner: girişsiz kullanıcının manuel listesi 3. programa
// ulaşınca bir kez görünen "listen yalnızca bu tarayıcıda" hatırlatması.
// /sonuc (funnel panelleriyle yarışmasın) ve giriş gibi kromsuz sayfalar
// hariç her yerde görünür; kapatma oturum boyunca kalıcıdır (sessionStorage).
// Exit-intent/agresif popup YOK (PRD kuralı).
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ArrowRight, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ViewportPortal } from "@/components/viewport-portal";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { olay } from "@/lib/analitik";
import { kromGizliMi } from "@/components/header-gate";
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
import { useManuelListe } from "./store";
import { useOturumDurumu } from "./oturum-sinyali";
import { useGirisUrl } from "./giris-url";
const KAPATMA_KEY = "kolaytercih.kaydet-banner-kapali";
const ESIK = 3;
export function KaydetBanner() {
const liste = useManuelListe();
const girisli = useOturumDurumu();
const pathname = usePathname();
const girisUrl = useGirisUrl();
// SSR/ilk boyada gizli başla; kapatma durumu mount'ta okunur.
const [kapali, setKapali] = useState(true);
useEffect(() => {
try {
// eslint-disable-next-line react-hooks/set-state-in-effect
setKapali(Boolean(sessionStorage.getItem(KAPATMA_KEY)));
} catch {
setKapali(false);
}
}, []);
const gorunumRef = useGorunumOlayi<HTMLDivElement>(
"kaydet_banner_goruntulendi",
);
if (girisli !== false || liste.length < ESIK || kapali) return null;
// /sonuc'ta gösterilmez: sayfanın kendi funnel panelleri var.
if (pathname?.startsWith("/sonuc")) return null;
// Giriş gibi kromsuz sayfalarda gösterilmez: kullanıcı zaten giriş
// akışındayken "Giriş yap" CTA'sı gereksiz gürültü olur.
if (pathname && kromGizliMi(pathname)) return null;
return (
<ViewportPortal>
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
<div
ref={gorunumRef}
className="pointer-events-auto mx-auto flex max-w-xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl"
>
{/* Kullanıcının o anki işletim sisteminin logosu: "bu tarayıcıda"
mesajını somutlaştırır */}
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 text-slate-700">
<IsletimSistemiLogosu className="size-5" />
</div>
<div className="flex-1">
<p className="font-heading text-sm font-bold text-slate-900">
Listen yalnızca bu tarayıcıda
</p>
<p className="text-xs text-slate-500">
Giriş yap; {liste.length} seçimin hesabında saklansın, her
cihazdan {" "}
<CihazCifti className="ml-0.5 align-[-3px] text-slate-400" />
</p>
</div>
<Button
asChild
size="sm"
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link
href={girisUrl}
onClick={() =>
olay("giris_cta_tiklandi", { kaynak: "liste_kaydet_banner" })
}
>
Giriş yap, kaydet
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<button
type="button"
aria-label="Kapat"
onClick={() => {
setKapali(true);
try {
sessionStorage.setItem(KAPATMA_KEY, "1");
} catch {}
}}
className="cursor-pointer rounded-full p-1 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
>
<X className="size-4" aria-hidden />
</button>
</div>
</div>
</ViewportPortal>
);
}

View File

@@ -0,0 +1,91 @@
"use server";
// Manuel liste + sihirbaz profilinin sunucu kaydı (İP-C). Upsert deseni
// reports ile aynıdır (bkz. sonuc/actions.ts): userId unique, tek satır.
//
// Senkron kuralları (PRD C1):
// - Giriş anında listeSenkronla: çakışmada CLIENT kazanır (lokal sıra başa,
// sunucudaki eksik id'ler sona), 24 sınırı korunur; idempotent — aynı
// girdiyle tekrar çağrı aynı sonucu üretir, çift kayıt imkânsız.
// - Girişliyken listeyiKaydet: localStorage tek kaynaktır, sunucu kaydının
// üzerine yazılır (son yazan cihaz kazanır).
import { eq } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { tercihProfiliDogrula, type TercihProfili } from "@/lib/sihirbaz";
import {
MANUEL_LISTE_MAX,
dogrula,
type ManuelTercih,
} from "@/components/manuel-liste/dogrula";
export type ListeSenkronSonucu =
| { ok: true; items: ManuelTercih[]; profil: TercihProfili | null }
| { ok: false };
async function kaydiYaz(
userId: string,
items: ManuelTercih[],
profil: TercihProfili | null,
): Promise<void> {
const now = new Date();
await appDb
.insert(schema.savedLists)
.values({
id: crypto.randomUUID(),
userId,
items,
profil,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: schema.savedLists.userId,
set: { items, profil, updatedAt: now },
});
}
/** Giriş anı merge'i: lokal + sunucu birleşir, kanonik sonuç döner. */
export async function listeSenkronla(girdi: {
items: unknown;
profil: unknown;
}): Promise<ListeSenkronSonucu> {
const session = await getSession();
if (!session) return { ok: false };
const userId = session.user.id;
const clientItems = dogrula(girdi.items);
const clientProfil = tercihProfiliDogrula(girdi.profil);
const mevcut = await appDb.query.savedLists.findFirst({
where: eq(schema.savedLists.userId, userId),
});
const sunucuItems = dogrula(mevcut?.items);
const sunucuProfil = tercihProfiliDogrula(mevcut?.profil);
const clientIdler = new Set(clientItems.map((t) => t.id));
const items = [
...clientItems,
...sunucuItems.filter((t) => !clientIdler.has(t.id)),
].slice(0, MANUEL_LISTE_MAX);
const profil = clientProfil ?? sunucuProfil;
await kaydiYaz(userId, items, profil);
return { ok: true, items, profil };
}
/** Girişliyken süregelen senkron: lokal durum sunucunun üzerine yazılır. */
export async function listeyiKaydet(girdi: {
items: unknown;
profil: unknown;
}): Promise<{ ok: boolean }> {
const session = await getSession();
if (!session) return { ok: false };
await kaydiYaz(
session.user.id,
dogrula(girdi.items),
tercihProfiliDogrula(girdi.profil),
);
return { ok: true };
}

View File

@@ -45,7 +45,11 @@ import {
} from "motion/react";
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
import { olay } from "@/lib/analitik";
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
import { sonucaGitIste } from "./profil-kapisi-store";
import { useOturumDurumu } from "./oturum-sinyali";
import { useGirisUrl } from "./giris-url";
import {
MANUEL_LISTE_MAX,
cekmeceAc,
@@ -81,6 +85,8 @@ export function ListeCekmecesi() {
const router = useRouter();
const acik = useCekmeceAcik();
const liste = useManuelListe();
const girisli = useOturumDurumu();
const girisUrl = useGirisUrl();
// Giriş animasyonu bitmeden satır layout projeksiyonu kapalı — transform'lu
// drawer içinde erken ölçüm satırları kaydırıyordu
const [oturdu, setOturdu] = useState(false);
@@ -216,7 +222,7 @@ export function ListeCekmecesi() {
<button
type="button"
onClick={programaEkleGit}
className={`flex h-12 w-full cursor-pointer items-center justify-center rounded-xl border border-dashed border-slate-300 text-slate-400 transition-colors duration-200 hover:border-primary/50 hover:bg-primary/5 hover:text-primary active:scale-[0.98] ${
className={`flex h-12 w-full cursor-pointer items-center justify-center rounded-full border border-dashed border-slate-300 text-slate-400 transition-colors duration-200 hover:border-primary/50 hover:bg-primary/5 hover:text-primary active:scale-[0.98] ${
liste.length > 0 ? "mt-2" : "mt-1"
}`}
>
@@ -227,6 +233,32 @@ export function ListeCekmecesi() {
</div>
<DrawerFooter className="border-t border-slate-100 pt-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
{/* C2 kalıcı satır: girişsiz kullanıcıya listenin uçucu olduğu
hatırlatılır; giriş doğal "kaydet" gerekçesi olarak sunulur. */}
{girisli === false ? (
<div className="mb-3 flex items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-left">
<p className="min-w-0 text-xs leading-relaxed text-slate-600">
{/* O anki işletim sisteminin logosu — "bu tarayıcıda"yı somutlaştırır */}
<IsletimSistemiLogosu className="mr-1.5 inline size-3.5 align-[-3px] text-slate-700" />
<span className="font-semibold text-slate-800">
Listen yalnızca bu tarayıcıda.
</span>{" "}
Giriş yap; her cihazdan eriş{" "}
<CihazCifti className="align-[-3px] text-slate-400" />
</p>
<Link
href={girisUrl}
onClick={() => {
cekmeceKapat();
olay("giris_cta_tiklandi", { kaynak: "liste_cekmecesi" });
}}
className="inline-flex shrink-0 items-center gap-1 text-xs font-semibold text-primary transition-colors duration-200 hover:text-primary/80"
>
Giriş yap, kaydet
<ArrowRight className="size-3.5" aria-hidden />
</Link>
</div>
) : null}
{/* Ana sayfadaki final CTA panelinin kompakt hâli: koyu primary
zemin + kenarlara doğru yoğunlaşan piksel dokusu (PixelField) */}
<div className="relative isolate flex items-center justify-between gap-4 overflow-hidden rounded-2xl bg-primary px-5 py-5 text-left text-white">
@@ -247,7 +279,7 @@ export function ListeCekmecesi() {
<Button
asChild
size="lg"
className="h-11 shrink-0 cursor-pointer bg-orange-500 px-6 text-white transition-colors duration-200 hover:bg-orange-600"
className="shrink-0 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/listem" onClick={cekmeceKapat}>
Listemde

View File

@@ -0,0 +1,100 @@
"use client";
// Manuel liste + sihirbaz profilinin sunucu senkronu (İP-C). UserNav'dan
// (sunucu bileşeni) userId prop'uyla monte edilir — RybbitIdentify ile aynı
// kanonik desen. Hiçbir şey render etmez.
//
// - Mount: oturum sinyali yayılır (çekmece/banner girişsizliği buradan öğrenir).
// - Girişte: oturum başına 1 kez merge (çakışmada lokal kazanır); dönen
// kanonik liste lokale birleşir, profil lokalde yoksa tercihProfiliYaz ile
// iner (PROFIL_DEGISTI_EVENT otomatik yayılır, profil kapısı tazelenir).
// - Sonrasında: liste/profil değişimleri ~2 sn debounce ile sunucuya yazılır.
import { useEffect, useRef } from "react";
import {
PROFIL_DEGISTI_EVENT,
tercihProfiliOku,
tercihProfiliYaz,
} from "@/lib/sihirbaz";
import { listeOku, sunucuylaBirlestir, useManuelListe } from "./store";
import { oturumDurumuBildir } from "./oturum-sinyali";
import { listeSenkronla, listeyiKaydet } from "./kayitli-liste-actions";
const DEBOUNCE_MS = 2000;
export function ListeSenkron({ userId }: { userId: string | null }) {
const liste = useManuelListe();
const mergeTamam = useRef(false);
const sonGonderilen = useRef<string | null>(null);
const zamanlayici = useRef<number | null>(null);
useEffect(() => {
oturumDurumuBildir(Boolean(userId));
}, [userId]);
// Giriş anı merge'i — sekme oturumu başına bir kez (idempotent olsa da
// her gezinmede sunucuya gitmesin diye sessionStorage ile tekilleştirilir).
useEffect(() => {
if (!userId) return;
const anahtar = `kolaytercih.liste-senkron.${userId}`;
try {
if (sessionStorage.getItem(anahtar)) {
mergeTamam.current = true;
return;
}
} catch {}
let iptal = false;
void (async () => {
const sonuc = await listeSenkronla({
items: listeOku(),
profil: tercihProfiliOku(),
});
if (iptal || !sonuc.ok) return;
sunucuylaBirlestir(sonuc.items);
if (sonuc.profil && !tercihProfiliOku()) {
tercihProfiliYaz(sonuc.profil);
}
// Merge sonrası anlık durumu "gönderildi" say — debounce aynı veriyi
// hemen geri yazmasın.
sonGonderilen.current = JSON.stringify({
items: listeOku(),
profil: tercihProfiliOku(),
});
mergeTamam.current = true;
try {
sessionStorage.setItem(anahtar, "1");
} catch {}
})();
return () => {
iptal = true;
};
}, [userId]);
// Girişliyken süregelen senkron: liste (store aboneliği) ve profil
// (PROFIL_DEGISTI_EVENT) değişimlerini debounce ile sunucuya it.
useEffect(() => {
if (!userId) return;
const planla = () => {
if (zamanlayici.current != null) window.clearTimeout(zamanlayici.current);
zamanlayici.current = window.setTimeout(() => {
if (!mergeTamam.current) return;
const items = listeOku();
const profil = tercihProfiliOku();
const imza = JSON.stringify({ items, profil });
if (imza === sonGonderilen.current) return;
sonGonderilen.current = imza;
void listeyiKaydet({ items, profil });
}, DEBOUNCE_MS);
};
planla();
window.addEventListener(PROFIL_DEGISTI_EVENT, planla);
return () => {
window.removeEventListener(PROFIL_DEGISTI_EVENT, planla);
if (zamanlayici.current != null) window.clearTimeout(zamanlayici.current);
};
}, [userId, liste]);
return null;
}

View File

@@ -0,0 +1,34 @@
"use client";
// Oturum durumunun client tarafındaki hafif sinyali. Kaynak: UserNav (sunucu
// bileşeni) → ListeSenkron mount'ta bildirir. Çekmece ve kaydet banner'ı
// authClient round-trip'i atmadan "girişsiz mi?" bilgisine buradan erişir
// (layout'a useSession koymak landing'i statiklikten çıkarırdı
// rybbit-identify.tsx'teki gerekçe).
import { useSyncExternalStore } from "react";
let girisli: boolean | null = null;
const dinleyiciler = new Set<() => void>();
export function oturumDurumuBildir(deger: boolean): void {
if (girisli === deger) return;
girisli = deger;
for (const cb of dinleyiciler) cb();
}
function abone(cb: () => void) {
dinleyiciler.add(cb);
return () => {
dinleyiciler.delete(cb);
};
}
/** null = henüz bilinmiyor (sinyal gelmedi); banner bu durumda gösterilmez. */
export function useOturumDurumu(): boolean | null {
return useSyncExternalStore(
abone,
() => girisli,
() => null,
);
}

View File

@@ -39,12 +39,17 @@ export type BekleyenProgram = {
adayTur?: string;
};
/** Kapı tamamlanınca nereye gideceği (sıra yazıldıktan sonra href üretilir). */
export type BekleyenHedef = "sonuc";
/** Kapı tamamlanınca nereye gideceği (sıra yazıldıktan sonra href üretilir).
* "sonuc": ücretsiz tabloya götürür. "sihirbaz": Yapay Zeka niyetli CTA'lardan
* gelindi — /sonuc'ta üretim akışı devam etsin (girişliyse otomatik üretim,
* girişsizse giriş CTA paneli; hero-form ile aynı desen). */
export type BekleyenHedef = "sonuc" | "sihirbaz";
type KapıDurumu = {
acik: boolean;
duzenle: boolean;
/** Tanıtım (giriş) adımını atla, doğrudan sıralama adımıyla aç. */
dogrudanSira: boolean;
bekleyen: BekleyenProgram | null;
bekleyenHedef: BekleyenHedef | null;
profil: TercihProfili | null;
@@ -54,6 +59,7 @@ type KapıDurumu = {
let durum: KapıDurumu = {
acik: false,
duzenle: false,
dogrudanSira: false,
bekleyen: null,
bekleyenHedef: null,
profil: null,
@@ -104,6 +110,7 @@ function abone(cb: () => void) {
const SSR_DURUM: KapıDurumu = {
acik: false,
duzenle: false,
dogrudanSira: false,
bekleyen: null,
bekleyenHedef: null,
profil: null,
@@ -130,6 +137,7 @@ export function profilKapisiniKapat() {
...durum,
acik: false,
duzenle: false,
dogrudanSira: false,
bekleyen: null,
bekleyenHedef: null,
};
@@ -142,6 +150,7 @@ export function profilDuzenlemeyiAc() {
...durum,
acik: true,
duzenle: true,
dogrudanSira: false,
bekleyen: null,
bekleyenHedef: null,
};
@@ -151,13 +160,18 @@ export function profilDuzenlemeyiAc() {
/**
* Profil yokken kapıyı açar; tamamlanınca `hedef`'e yönlendirilir.
* Navbar, çekmece +, /sonuc boş hali ve listem gibi yerler bunu kullanır.
* `dogrudanSira` tanıtım adımını atlar — kapı sıralama adımıyla açılır.
*/
export function profilToplamaAc(hedef: BekleyenHedef | null = null) {
export function profilToplamaAc(
hedef: BekleyenHedef | null = null,
dogrudanSira = false,
) {
if (!durum.profilYuklendi) yukle();
durum = {
...durum,
acik: true,
duzenle: false,
dogrudanSira,
bekleyen: null,
bekleyenHedef: hedef,
};
@@ -167,8 +181,9 @@ export function profilToplamaAc(hedef: BekleyenHedef | null = null) {
/**
* Sonuç sayfasına gitmek isteyen her yer: profil varsa href,
* yoksa sıralama/tercih kapısını açar (tamamlanınca /sonuc'a gider).
* `dogrudanSira`, kapıılırsa tanıtım adımını atlatır (navbar CTA'sı).
*/
export function sonucaGitIste(): {
export function sonucaGitIste(secenekler?: { dogrudanSira?: boolean }): {
href: string | null;
kapiAcildi: boolean;
} {
@@ -177,7 +192,7 @@ export function sonucaGitIste(): {
if (profil) {
return { href: sonucHref(profil), kapiAcildi: false };
}
profilToplamaAc("sonuc");
profilToplamaAc("sonuc", secenekler?.dogrudanSira ?? false);
return { href: null, kapiAcildi: true };
}
@@ -203,6 +218,7 @@ export function programEkleIste(opts: {
...durum,
acik: true,
duzenle: false,
dogrudanSira: false,
bekleyen: { program, kaynak, adaySira, adayTur },
bekleyenHedef: null,
};
@@ -227,8 +243,13 @@ export function programEkleIste(opts: {
return { eklendi: true, zatenVar: false, doldu: false, kapiAcildi: false };
}
/** Profil yazıldıktan sonra; bekleyen hedef varsa yönlendirme href'i döner. */
export function profilTamamlandi(profil: TercihProfili): string | null {
/** Profil yazıldıktan sonra; bekleyen hedef varsa yönlendirme href'i döner.
* `girisli`, "sihirbaz" hedefinde üretimin otomatik başlayıp başlamayacağını
* belirler (kapı bileşeni oturumu facet isteğiyle paralel çözer). */
export function profilTamamlandi(
profil: TercihProfili,
girisli = false,
): string | null {
const onceki = durum.profil;
const duzenle = durum.duzenle;
riskleriYenile(profil);
@@ -241,12 +262,19 @@ export function profilTamamlandi(profil: TercihProfili): string | null {
...durum,
acik: false,
duzenle: false,
dogrudanSira: false,
bekleyen: null,
bekleyenHedef: null,
profil,
profilYuklendi: true,
};
yayinla();
if (hedef === "sihirbaz") {
// Yapay Zeka niyetli giriş: kapıda sihirbazı zaten doldurdu, /sonuc'ta
// akış kesilmesin — girişliyse üretim otomatik başlar (?sihirbaz=1),
// girişsizse ücretsiz tablo + giriş CTA paneli (?hazir=1) karşılar.
return sonucHref(profil, girisli ? { sihirbaz: "1" } : { hazir: "1" });
}
if (hedef === "sonuc") return sonucHref(profil);
// Düzenlemede sıra/tür değişince /sonuc?sira= URL'si yenilenmezse sunucudaki
// hayal/dengeli/garanti tablosu eski sıralamada kalır (profil localStorage'da

View File

@@ -7,31 +7,24 @@
import { useSyncExternalStore } from "react";
import type { Program } from "@/lib/db";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { riskHesapla } from "@/lib/risk";
import {
tercihProfiliOku,
type TercihProfili,
} from "@/lib/sihirbaz";
import {
MANUEL_LISTE_MAX,
dogrula,
type ManuelKaynak,
type ManuelTercih,
} from "./dogrula";
// Doğrulama katmanı sunucu action'larıyla paylaşılır (bkz. dogrula.ts);
// mevcut import yolları kırılmasın diye buradan re-export edilir.
export { MANUEL_LISTE_MAX, type ManuelKaynak, type ManuelTercih };
export const MANUEL_LISTE_MAX = 24;
const STORAGE_KEY = "kolaytercih.manuel-liste.v1";
export type ManuelKaynak = "sonuc" | "bolum" | "universite";
export type ManuelTercih = {
/** yokatlas program id'si */
id: string;
isim: string;
universite: string;
il: string | null;
unitur: string | null;
tur: string | null;
efektifSira: number | null;
/** Ekleme / profil anındaki aday sıralamasına göre risk */
risk: RiskSeviyesi | null;
kaynak?: ManuelKaynak;
};
// SSR snapshot'ı: her çağrıda aynı referans dönmeli, yoksa React döngüye girer
const BOS_LISTE: ManuelTercih[] = [];
@@ -51,45 +44,6 @@ function kaydet() {
} catch {}
}
function dogrula(ham: unknown): ManuelTercih[] {
if (!Array.isArray(ham)) return [];
const sonuc: ManuelTercih[] = [];
for (const t of ham) {
if (typeof t !== "object" || t === null) continue;
const kayit = t as Record<string, unknown>;
if (typeof kayit.id !== "string" || typeof kayit.isim !== "string") continue;
const risk: RiskSeviyesi | null =
kayit.risk === "guvenli" ||
kayit.risk === "az-riskli" ||
kayit.risk === "riskli"
? kayit.risk
: null;
const kaynak: ManuelKaynak | undefined =
kayit.kaynak === "sonuc" ||
kayit.kaynak === "bolum" ||
kayit.kaynak === "universite"
? kayit.kaynak
: undefined;
sonuc.push({
id: kayit.id,
isim: kayit.isim,
universite: typeof kayit.universite === "string" ? kayit.universite : "",
il: typeof kayit.il === "string" ? kayit.il : null,
unitur: typeof kayit.unitur === "string" ? kayit.unitur : null,
tur: typeof kayit.tur === "string" ? kayit.tur : null,
efektifSira:
typeof kayit.efektifSira === "number" &&
Number.isFinite(kayit.efektifSira)
? kayit.efektifSira
: null,
risk,
kaynak,
});
if (sonuc.length >= MANUEL_LISTE_MAX) break;
}
return sonuc;
}
function yukle() {
if (yuklendi || typeof window === "undefined") return;
yuklendi = true;
@@ -139,6 +93,28 @@ export function listedeMi(id: string): boolean {
return liste.some((t) => t.id === id);
}
/** Senkron snapshot — giriş anı merge'inde sunucuya gönderilecek liste. */
export function listeOku(): ManuelTercih[] {
yukle();
return liste;
}
/**
* Sunucudan dönen kanonik listeyi lokale birleştirir: lokal sıra korunur,
* lokalde olmayan sunucu kayıtları sona eklenir, 24 sınırıılmaz.
* (Çakışmada localStorage kazanır — PRD C1 kuralı.)
*/
export function sunucuylaBirlestir(sunucuItems: ManuelTercih[]): ManuelTercih[] {
yukle();
const mevcut = new Set(liste.map((t) => t.id));
const ekler = sunucuItems.filter((t) => !mevcut.has(t.id));
if (ekler.length === 0) return liste;
liste = [...liste, ...ekler].slice(0, MANUEL_LISTE_MAX);
kaydet();
yayinla();
return liste;
}
export function efektifSiraAl(
p: Pick<Program, "sira2025" | "sira2024">,
): number | null {

View File

@@ -33,10 +33,12 @@ import {
type SihirbazSecimleri,
type TercihProfili,
} from "@/lib/sihirbaz";
import { authClient } from "@/lib/auth-client";
import {
profilKapisiniKapat,
profilTamamlandi,
useProfilKapisi,
type BekleyenHedef,
type BekleyenProgram,
} from "./profil-kapisi-store";
@@ -53,7 +55,8 @@ type Adim = "giris" | "sira" | "sihirbaz";
const ADIM_SIRASI: Record<Adim, number> = { giris: 0, sira: 1, sihirbaz: 2 };
export function TercihProfiliKapisi() {
const { acik, duzenle, bekleyen, profil } = useProfilKapisi();
const { acik, duzenle, dogrudanSira, bekleyen, bekleyenHedef, profil } =
useProfilKapisi();
const oturumAnahtari = acik
? `${duzenle ? "d" : "n"}-${bekleyen?.program.id ?? "yok"}-${profil?.sira ?? 0}-${profil?.tur ?? ""}`
: "kapali";
@@ -80,7 +83,9 @@ export function TercihProfiliKapisi() {
<KapıIcerik
key={oturumAnahtari}
duzenle={duzenle}
dogrudanSira={dogrudanSira}
bekleyen={bekleyen}
hedef={bekleyenHedef}
profil={profil}
/>
) : null}
@@ -91,19 +96,25 @@ export function TercihProfiliKapisi() {
function KapıIcerik({
duzenle,
dogrudanSira,
bekleyen,
hedef,
profil,
}: {
duzenle: boolean;
dogrudanSira: boolean;
bekleyen: BekleyenProgram | null;
hedef: BekleyenHedef | null;
profil: TercihProfili | null;
}) {
const router = useRouter();
const bilinenSira = bekleyen?.adaySira ?? profil?.sira ?? null;
const bilinenTur = bekleyen?.adayTur ?? profil?.tur ?? "say";
// Düzenlemede doğrudan sıralama adımı — yeni sıra + tercihler aynı oturumda
const [adim, setAdim] = useState<Adim>(duzenle ? "sira" : "giris");
// Düzenlemede ve navbar "Sıralamanı gir" girişinde doğrudan sıralama adımı
const [adim, setAdim] = useState<Adim>(
duzenle || dogrudanSira ? "sira" : "giris",
);
// Adım geçişleri sihirbazın kendi içindeki TransitionPanel kaymasıyla oynar
const [yon, setYon] = useState(1);
const [gecisBasladi, setGecisBasladi] = useState(false);
@@ -117,6 +128,9 @@ function KapıIcerik({
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
const [yukleniyor, setYukleniyor] = useState(false);
const [hata, setHata] = useState<string | null>(null);
// "sihirbaz" hedefinde yönlendirme ve son buton etiketi oturuma göre
// değişir; oturum facet isteğiyle paralel çözülür (hero-form deseni).
const [girisli, setGirisli] = useState(false);
function siralamaDegisti(ham: string) {
setHata(null);
@@ -134,11 +148,13 @@ function KapıIcerik({
setYukleniyor(true);
setHata(null);
try {
const res = await fetch(
`/api/facetler?sira=${siraDeger}&tur=${turDeger}`,
);
const [res, oturum] = await Promise.all([
fetch(`/api/facetler?sira=${siraDeger}&tur=${turDeger}`),
authClient.getSession().catch(() => null),
]);
if (!res.ok) throw new Error();
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
setGirisli(Boolean(oturum?.data?.user));
setSira(siraDeger);
setTur(turDeger);
setFacetler(veri.facetler);
@@ -179,8 +195,10 @@ function KapıIcerik({
const yeniProfil: TercihProfili = { sira, tur, secimler };
tercihProfiliYaz(yeniProfil);
const bekleyenVar = Boolean(bekleyen);
const yonlendir = profilTamamlandi(yeniProfil);
if (!duzenle && !bekleyenVar) {
const yonlendir = profilTamamlandi(yeniProfil, girisli);
// "sihirbaz" hedefinde akış /sonuc'ta devam eder (üretim ya da giriş
// paneli); "kaydedildi" toast'ı akış bitti izlenimi verirdi.
if (!duzenle && !bekleyenVar && hedef !== "sihirbaz") {
toast.success("Tercihlerin kaydedildi.");
}
// Düzenlemede replace: aynı /sonuc sayfasında sira/tur değişince geçmiş
@@ -363,11 +381,13 @@ function KapıIcerik({
type="button"
variant="ghost"
onClick={() =>
duzenle ? profilKapisiniKapat() : adimaGec("giris")
// Doğrudan sıralama adımıyla açıldıysa tanıtım adımı hiç
// görülmedi; "Geri" yerine kapıyı kapatmak beklenen davranış.
duzenle || dogrudanSira ? profilKapisiniKapat() : adimaGec("giris")
}
className="cursor-pointer"
>
{duzenle ? "Vazgeç" : "Geri"}
{duzenle || dogrudanSira ? "Vazgeç" : "Geri"}
</Button>
<Button
type="submit"
@@ -399,7 +419,11 @@ function KapıIcerik({
? "Tercihlerimi güncelle"
: bekleyen
? "Kaydet ve programı ekle"
: "Tercihlerimi kaydet"
: hedef === "sihirbaz"
? girisli
? "Yapay Zeka listemi kur (3 kredi)"
: "Ücretsiz tablonu gör"
: "Tercihlerimi kaydet"
}
onTamamla={secimleriTamamla}
/>

View File

@@ -1,12 +1,31 @@
import { TrendingDown, TrendingUp } from "lucide-react";
import type { Program } from "@/lib/db";
export function programEtkinSira(program: Program): number | null {
/** Taban sıra sütunlarının yapısal alt kümesi: hem DB programı hem de
* rapordaki kaydedilmiş sıra geçmişi (bkz. raporSiraSerisi) bu tipe uyar. */
export type SiraSerisi = Pick<
Program,
"sira2021" | "sira2022" | "sira2023" | "sira2024" | "sira2025"
>;
/** Rapordaki [2021..2025] dizisini sütunlu seriye çevirir. */
export function raporSiraSerisi(gecmis?: (number | null)[]): SiraSerisi {
const g = gecmis ?? [];
return {
sira2021: g[0] ?? null,
sira2022: g[1] ?? null,
sira2023: g[2] ?? null,
sira2024: g[3] ?? null,
sira2025: g[4] ?? null,
};
}
export function programEtkinSira(program: SiraSerisi): number | null {
return program.sira2025 ?? program.sira2024;
}
/** 20212025 taban sıralamasını sonuç listesindeki kompakt çizgiyle gösterir. */
export function ProgramSiraGecmisi({ program }: { program: Program }) {
export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
const yillar = [
[2021, program.sira2021],
[2022, program.sira2022],
@@ -61,7 +80,7 @@ export function ProgramSiraGecmisi({ program }: { program: Program }) {
}
/** Son iki yılın tabanı %10'dan fazla değiştiğinde yönü işaretler. */
export function ProgramTabanTrendi({ program }: { program: Program }) {
export function ProgramTabanTrendi({ program }: { program: SiraSerisi }) {
if (program.sira2025 == null || program.sira2024 == null) return null;
const oran = (program.sira2024 - program.sira2025) / program.sira2024;

View File

@@ -0,0 +1,221 @@
"use client";
// Program satırındaki "netler" butonu: YÖK Atlas'ın "yerleşen son kişinin
// netleri" verisini (data/yokatlas.db → netler tablosu, /api/netler) yıl
// sütunlu kompakt bir modalde gösterir. Kadın-erkek dağılımı bilinçli olarak
// yok: YÖK Atlas 2026 SPA'sına geçerken cinsiyet panelini yayından kaldırdı
// (bkz. scripts/detay.ts).
import { useState } from "react";
import { ListChecks } from "lucide-react";
import type { ProgramNetSatiri } from "@/lib/db";
import { olay } from "@/lib/analitik";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Ders sırası ÖSYM oturum sırasıyla; max = testteki soru sayısı.
const DERSLER: {
alan: keyof Omit<ProgramNetSatiri, "yil" | "puanTuru" | "tabanPuan" | "obp">;
etiket: string;
max: number;
}[] = [
{ alan: "tytTrkNet", etiket: "TYT Türkçe", max: 40 },
{ alan: "tytSosNet", etiket: "TYT Sosyal Bilimler", max: 20 },
{ alan: "tytMatNet", etiket: "TYT Matematik", max: 40 },
{ alan: "tytFenNet", etiket: "TYT Fen Bilimleri", max: 20 },
{ alan: "aytMatNet", etiket: "AYT Matematik", max: 40 },
{ alan: "aytFizNet", etiket: "AYT Fizik", max: 14 },
{ alan: "aytKimNet", etiket: "AYT Kimya", max: 13 },
{ alan: "aytBioNet", etiket: "AYT Biyoloji", max: 13 },
{ alan: "aytTdeNet", etiket: "AYT Türk Dili ve Edebiyatı", max: 24 },
{ alan: "aytTrh1Net", etiket: "AYT Tarih-1", max: 10 },
{ alan: "aytCog1Net", etiket: "AYT Coğrafya-1", max: 6 },
{ alan: "aytTrh2Net", etiket: "AYT Tarih-2", max: 11 },
{ alan: "aytCog2Net", etiket: "AYT Coğrafya-2", max: 11 },
{ alan: "aytDinNet", etiket: "AYT Din Kültürü", max: 6 },
{ alan: "aytFelNet", etiket: "AYT Felsefe Grubu", max: 12 },
{ alan: "ydtYdilNet", etiket: "YDT Yabancı Dil", max: 80 },
];
const net = (n: number) =>
n.toLocaleString("tr-TR", { maximumFractionDigits: 2 });
type Durum = "bekliyor" | "yukleniyor" | "hazir" | "hata";
export function ProgramNetleriButonu({
programId,
isim,
universite,
className = "",
}: {
programId: string;
isim: string;
universite?: string;
className?: string;
}) {
const [durum, setDurum] = useState<Durum>("bekliyor");
const [netler, setNetler] = useState<ProgramNetSatiri[]>([]);
async function yukle() {
setDurum("yukleniyor");
try {
const res = await fetch(`/api/netler?id=${programId}`);
if (!res.ok) throw new Error(String(res.status));
const veri = (await res.json()) as { netler: ProgramNetSatiri[] };
setNetler(veri.netler);
setDurum("hazir");
} catch {
setDurum("hata");
}
}
function acildi(acik: boolean) {
if (!acik) return;
olay("netler_goruntulendi");
if (durum === "bekliyor" || durum === "hata") yukle();
}
return (
<Dialog onOpenChange={acildi}>
<DialogTrigger asChild>
<button
type="button"
title="Son yerleşenin netleri"
onClick={(e) => e.stopPropagation()}
className={`inline-flex size-11 min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-primary hover:bg-primary/5 hover:text-primary active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`}
>
<ListChecks className="size-4" aria-hidden />
<span className="sr-only">{isim} son yerleşenin netleri</span>
</button>
</DialogTrigger>
<DialogContent
className="sm:max-w-md"
onClick={(e) => e.stopPropagation()}
>
<DialogHeader>
<DialogTitle className="pr-10">Son yerleşenin netleri</DialogTitle>
<DialogDescription>
{isim}
{universite ? ` · ${universite}` : ""} bölüme en düşük puanla
yerleşen adayın sınav netleri (YÖK Atlas).
</DialogDescription>
</DialogHeader>
<NetlerIcerik durum={durum} netler={netler} />
</DialogContent>
</Dialog>
);
}
function NetlerIcerik({
durum,
netler,
}: {
durum: Durum;
netler: ProgramNetSatiri[];
}) {
if (durum === "yukleniyor" || durum === "bekliyor") {
return (
<div className="space-y-2" aria-hidden>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="h-6 animate-pulse rounded-md bg-slate-100" />
))}
</div>
);
}
if (durum === "hata") {
return (
<p className="py-4 text-center text-sm text-slate-500">
Netler yüklenemedi. Modalı kapatıp yeniden açmayı dene.
</p>
);
}
if (netler.length === 0) {
return (
<p className="py-4 text-center text-sm text-slate-500">
Bu program için YÖK Atlas&apos;ta net verisi yayınlanmamış.
</p>
);
}
const dersler = DERSLER.filter((d) =>
netler.some((y) => y[d.alan] != null),
);
return (
<div className="-mx-1 overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="h-9">Ders</TableHead>
{netler.map((y) => (
<TableHead key={y.yil} className="h-9 text-right tabular-nums">
{y.yil}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{dersler.map((d) => (
<TableRow key={d.alan}>
<TableCell className="py-2 text-xs text-slate-600">
{d.etiket}
<span className="text-slate-400"> / {d.max}</span>
</TableCell>
{netler.map((y) => {
const deger = y[d.alan];
return (
<TableCell
key={y.yil}
className="py-2 text-right text-xs tabular-nums"
>
{deger != null ? net(deger) : "—"}
</TableCell>
);
})}
</TableRow>
))}
<TableRow>
<TableCell className="py-2 text-xs font-medium text-slate-600">
OBP
</TableCell>
{netler.map((y) => (
<TableCell
key={y.yil}
className="py-2 text-right text-xs tabular-nums text-slate-500"
>
{y.obp != null ? net(y.obp) : "—"}
</TableCell>
))}
</TableRow>
<TableRow>
<TableCell className="py-2 text-xs font-semibold text-slate-700">
Yerleşme puanı
</TableCell>
{netler.map((y) => (
<TableCell
key={y.yil}
className="py-2 text-right text-xs font-semibold tabular-nums text-slate-700"
>
{y.tabanPuan != null ? net(y.tabanPuan) : "—"}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
</div>
);
}

View File

@@ -15,7 +15,7 @@ import {
} from "lucide-react";
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/lib/db";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
@@ -31,6 +31,7 @@ import {
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
import { ProgramNetleriButonu } from "@/components/program-netleri";
import { UniLogo } from "@/components/uni-logo";
const DILIMLER: {
@@ -183,8 +184,8 @@ export function ProgramTablosu({
Kontenjan
</TableHead>
<TableHead className="text-right">Taban sıra</TableHead>
<TableHead className="w-16 text-right">
<span className="sr-only">Listeye ekle</span>
<TableHead className="w-24 text-right">
<span className="sr-only">Netler ve listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
@@ -226,22 +227,6 @@ export function ProgramTablosu({
);
}
// /universite/[slug] sayfa slug'ı: harita.ts'teki uniAdiNormalize ile aynı
// kural (sondaki "(İL)" eki atılır) — client bundle'a harita verisi çekmemek
// için burada tekrarlanır. Katalog haritası DB'deki tüm universite
// değerlerinden kurulduğu için bu slug her zaman var olan bir sayfaya çıkar.
function uniSayfaSlug(ad: string): string {
return turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim());
}
// /bolum/[slug] sayfa slug'ı: katalog.ts'teki bolumSlugFromIsim ile aynı kural
// (bolumBazAdi + turkishSlugify). Bölüm haritası DB'deki tüm isim
// değerlerinden kurulduğu için bu slug da her zaman var olan bir sayfaya çıkar
// (bkz. liste-cekmecesi'ndeki bolumHref).
function bolumSayfaSlug(isim: string): string {
return turkishSlugify(bolumBazAdi(isim));
}
function ProgramSatiri({
program: p,
adaySira,
@@ -336,13 +321,21 @@ function ProgramSatiri({
</span>
</TableCell>
<TableCell className="pr-4 text-right">
<ProgramEkleButonu
program={p}
kaynak="sonuc"
adaySira={adaySira}
adayTur={tur}
className="size-8 min-h-8 min-w-8"
/>
<span className="inline-flex items-center gap-1.5">
<ProgramNetleriButonu
programId={p.id}
isim={p.isim}
universite={p.universite}
className="size-8 min-h-8 min-w-8"
/>
<ProgramEkleButonu
program={p}
kaynak="sonuc"
adaySira={adaySira}
adayTur={tur}
className="size-8 min-h-8 min-w-8"
/>
</span>
</TableCell>
</TableRow>
);

View File

@@ -1,22 +1,32 @@
"use client";
// 24'lük listenin gövdesi. Program adı, 2024/2025 tabanları ve ham trend
// herkese görünür. Yapay Zeka gerekçesi ile kişisel risk, kilitliBaslangic sonrasında
// ücretli katman olarak ayrılır; ücretli metin sunucuda zaten maskelidir.
// 24'lük listenin gövdesi. Görsel dil sonuç sayfasındaki ham program
// tablosuyla birebir aynıdır (bkz. program-tablosu.tsx): aynı beyaz kart,
// aynı satır düzeni, aynı "Son 5 yıl" / "Taban sıra" sütunları. Buradaki fark
// içerik: satır sırası tercih numarası, risk sütunu Yapay Zeka'dan gelir ve
// satır açıldığında gerekçe/risk notu görünür. Program adı ve tabanlar herkese
// açık; Yapay Zeka katmanı kilitliBaslangic sonrasında ücretlidir (metin
// sunucuda zaten maskelidir).
import { Fragment, useEffect, useRef, useState } from "react";
import {
ChevronDown,
Lock,
MapPin,
Minus,
Rocket,
Scale,
ShieldCheck,
TrendingDown,
TrendingUp,
} from "lucide-react";
import Link from "next/link";
import { ChevronDown, Lock, MapPin, Rocket, Scale, ShieldCheck } from "lucide-react";
import { UniLogo } from "@/components/uni-logo";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
programEtkinSira,
raporSiraSerisi,
} from "@/components/program-liste-verileri";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
import type { RaporSonuc } from "@/lib/ai/rapor";
import {
RISK_ETIKET,
@@ -31,105 +41,66 @@ export const DILIM_ETIKET: Record<string, string> = {
garanti: "Garanti",
};
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli
const RISK_STIL: Record<
RiskSeviyesi,
{ nokta: string; kenar: string; icon: typeof Rocket }
> = {
riskli: { nokta: "bg-red-500", kenar: "border-l-red-500", icon: Rocket },
"az-riskli": {
nokta: "bg-amber-500",
kenar: "border-l-amber-500",
icon: Scale,
},
guvenli: {
nokta: "bg-emerald-500",
kenar: "border-l-emerald-500",
icon: ShieldCheck,
},
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli.
// Nokta hiçbir yerde tek başına durmaz; yanındaki etiket dar ekranda sr-only
// olarak kalır (renk tek başına anlam taşımaz).
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; icon: typeof Rocket }> = {
riskli: { nokta: "bg-red-500", icon: Rocket },
"az-riskli": { nokta: "bg-amber-500", icon: Scale },
guvenli: { nokta: "bg-emerald-500", icon: ShieldCheck },
};
const YILLAR = [2021, 2022, 2023, 2024, 2025];
type Trend = "zorlasiyor" | "kolaylasiyor" | "stabil";
/** Deterministik trend: ilk ve son bilinen taban sıralaması kıyaslanır.
* Sıralama sayısı küçüldüyse bölüm zorlaşıyor demektir. */
function trendYonu(gecmis?: (number | null)[]): Trend | null {
const dolu = (gecmis ?? []).filter((s): s is number => s != null);
if (dolu.length < 2) return null;
const oran = dolu[dolu.length - 1] / dolu[0];
if (oran < 0.88) return "zorlasiyor";
if (oran > 1.14) return "kolaylasiyor";
return "stabil";
}
const TREND_GORUNUM: Record<
Trend,
{ icon: typeof Minus; etiket: string; renk: string }
> = {
zorlasiyor: {
icon: TrendingUp,
etiket: "Taban zorlaşıyor",
renk: "text-red-500",
},
kolaylasiyor: {
icon: TrendingDown,
etiket: "Taban rahatlıyor",
renk: "text-emerald-600",
},
stabil: { icon: Minus, etiket: "Taban stabil", renk: "text-slate-400" },
};
/** Son 5 yılın taban sıralaması mini çizgisi (detay panelinde). */
function TrendCizgisi({ gecmis }: { gecmis: (number | null)[] }) {
const noktalar = YILLAR.map((yil, i) => ({ yil, sira: gecmis[i] })).filter(
(n): n is { yil: number; sira: number } => n.sira != null,
);
if (noktalar.length < 2) return null;
const W = 150;
const H = 40;
const min = Math.min(...noktalar.map((n) => n.sira));
const max = Math.max(...noktalar.map((n) => n.sira));
const aralik = Math.max(1, max - min);
// Küçük sıralama = daha zor bölüm = çizgide yukarıda
const nokta = (i: number, sira: number) => ({
x: 6 + (i / (noktalar.length - 1)) * (W - 12),
y: 8 + ((sira - min) / aralik) * (H - 16),
});
const path = noktalar
.map((n, i) => {
const { x, y } = nokta(i, n.sira);
return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
})
.join(" ");
const son = nokta(noktalar.length - 1, noktalar[noktalar.length - 1].sira);
// Sütun sayısı: # · Program · Son 5 yıl · Risk · Taban sıra · detay oku
const SUTUN_SAYISI = 6;
/**
* Açık satırın detay bloğu: dilim/risk başlığı + gerekçe ve risk/trend
* kartları. /sonuc'taki girişsiz tadımlık satırı da (tadimlik-satiri.tsx)
* aynı bileşeni kullanır — "demo değil ürünün kendisi" hissi kopyayla değil
* tek kaynakla sağlanır.
*/
export function RaporSatirDetayi({
dilim,
risk,
unitur,
gerekce,
riskNotu,
trendOzeti,
}: {
dilim: string;
risk: RiskSeviyesi;
unitur?: string | null;
gerekce: string;
riskNotu: string;
trendOzeti: string;
}) {
const stil = RISK_STIL[risk];
return (
<div className="mt-2 flex items-center gap-2">
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-10 w-37.5"
role="img"
aria-label={`Taban sıralaması ${noktalar[0].yil}: ${noktalar[0].sira.toLocaleString("tr-TR")}, ${noktalar[noktalar.length - 1].yil}: ${noktalar[noktalar.length - 1].sira.toLocaleString("tr-TR")}`}
>
<path
d={path}
fill="none"
stroke="oklch(0.623 0.188 259.8)"
strokeWidth={2}
strokeLinecap="round"
/>
<circle cx={son.x} cy={son.y} r={3} fill="oklch(0.623 0.188 259.8)" />
</svg>
<span className="text-xs text-slate-500">
{noktalar[0].yil}: ~{noktalar[0].sira.toLocaleString("tr-TR")}
<br />
{noktalar[noktalar.length - 1].yil}: ~
{noktalar[noktalar.length - 1].sira.toLocaleString("tr-TR")}
</span>
</div>
<>
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<stil.icon className="size-3.5" aria-hidden />
<span className="font-medium">
{DILIM_ETIKET[dilim]} · {RISK_ETIKET[risk]}
</span>
{unitur ? <span>· {unitur}</span> : null}
</div>
<dl className="grid gap-2 text-sm sm:grid-cols-2">
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Neden listede?
</dt>
<dd className="mt-1 text-slate-700">{gerekce}</dd>
</div>
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Risk ve Yapay Zeka yorumu
</dt>
<dd className="mt-1 text-slate-700">
{riskNotu} {trendOzeti}
</dd>
</div>
</dl>
</>
);
}
@@ -151,7 +122,7 @@ export function RaporListesi({
onHaritadaGor?: (il: string | null, sira: number) => void;
}) {
const [acik, setAcik] = useState<Set<number>>(new Set());
const satirRefs = useRef(new Map<number, HTMLLIElement>());
const satirRefs = useRef(new Map<number, HTMLTableRowElement>());
// Haritadan gelen seçim: satırı aç ve görünür alana kaydır
useEffect(() => {
@@ -174,15 +145,6 @@ export function RaporListesi({
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
// Dilim dağılımı: liste hayal diliminden başladığı için ilk ekran hep
// kırmızı görünür; özet, dengenin sağlıklı olduğunu tek bakışta gösterir.
const dilimSayilari = rapor.tercihler.reduce(
(t, x) => {
t[x.dilim] = (t[x.dilim] ?? 0) + 1;
return t;
},
{} as Record<string, number>,
);
const iller = new Set(
rapor.tercihler
.map((t) => rapor.programlar[t.programId]?.il)
@@ -190,252 +152,232 @@ export function RaporListesi({
);
const tekIl = iller.size === 1 ? [...iller][0] : null;
const satir = (
t: RaporSonuc["tercihler"][number],
index: number,
) => {
const satir = (t: RaporSonuc["tercihler"][number], index: number) => {
const p = rapor.programlar[t.programId];
const analizKilitli = index >= kilitIdx;
const risk =
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
dilimdenRisk(t.dilim);
const stil = RISK_STIL[risk];
const trend = trendYonu(p?.siraGecmisi);
const seri = raporSiraSerisi(p?.siraGecmisi);
const taban = p?.efektifSira ?? programEtkinSira(seri);
const acikMi = acik.has(t.sira);
const sira2024 = p?.siraGecmisi?.[3] ?? null;
const sira2025 = p?.siraGecmisi?.[4] ?? null;
// Son iki yıldan biri eksikse satır başındaki trend rozeti çelişkili
// okunur ("2025: —" ama "stabil") — rozet ancak iki yıl da doluysa çıkar;
// uzun vadeli trend detaydaki 20212025 çizgisinde durur.
const trendG =
trend && sira2024 != null && sira2025 != null
? TREND_GORUNUM[trend]
: null;
const devlet = p?.unitur === "DEVLET";
return (
<li
key={t.sira}
ref={(el) => {
if (el) satirRefs.current.set(t.sira, el);
else satirRefs.current.delete(t.sira);
}}
className={`overflow-hidden rounded-xl border bg-white transition-[border-color,box-shadow] duration-200 hover:border-slate-300 hover:shadow-xs ${
analizKilitli ? "border-slate-200" : `border-l-4 ${stil.kenar}`
}`}
>
<button
type="button"
<Fragment key={t.sira}>
<TableRow
ref={(el) => {
if (el) satirRefs.current.set(t.sira, el);
else satirRefs.current.delete(t.sira);
}}
onClick={() => tiklandi(t.sira)}
aria-expanded={acikMi}
className="grid min-h-16 w-full cursor-pointer grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 px-3 py-2.5 text-left xl:grid-cols-[auto_minmax(220px,1fr)_92px_92px_112px_82px_auto] xl:px-4"
className="cursor-pointer"
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
{t.sira}
</span>
<span className="flex min-w-0 items-center gap-2.5">
{p?.universite ? (
<UniLogo ad={p.universite} boy="sm" className="hidden sm:flex" />
) : null}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">
{p?.isim ?? t.programId}
<TableCell className="pl-4 pr-0">
<span className="flex size-7 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
{t.sira}
</span>
<span className="block truncate text-xs text-slate-500">
{p?.universite}
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
<span className="mt-1 flex items-center gap-3 text-[11px] tabular-nums text-slate-500 xl:hidden">
<span>2024: {sira2024?.toLocaleString("tr-TR") ?? "—"}</span>
<span>2025: {sira2025?.toLocaleString("tr-TR") ?? "—"}</span>
{trendG ? <span className={trendG.renk}>{trendG.etiket}</span> : null}
</span>
</span>
</span>
<span className="hidden text-right text-xs tabular-nums text-slate-600 xl:block">
{sira2024?.toLocaleString("tr-TR") ?? "—"}
</span>
<span className="hidden text-right text-xs font-semibold tabular-nums text-slate-900 xl:block">
{sira2025?.toLocaleString("tr-TR") ?? "—"}
</span>
<span className="hidden items-center gap-1.5 text-xs xl:flex">
{trendG ? (
<>
<trendG.icon className={`size-4 shrink-0 ${trendG.renk}`} aria-hidden />
<span className={trendG.renk}>
{trend === "zorlasiyor"
? "Zorlaşıyor"
: trend === "kolaylasiyor"
? "Rahatlıyor"
: "Stabil"}
</TableCell>
<TableCell className="max-w-0 w-full">
<span className="flex min-w-0 items-center gap-2.5">
{p?.universite ? <UniLogo ad={p.universite} boy="sm" /> : null}
<span className="min-w-0 flex-1">
{p ? (
<Link
href={`/bolum/${bolumSayfaSlug(p.isim)}`}
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={`${p.isim} bölüm sayfası`}
onClick={(e) => e.stopPropagation()}
>
{p.isim}
</Link>
) : (
<span className="block truncate text-sm font-semibold">
{t.programId}
</span>
)}
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
{p?.unitur ? (
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
devlet
? "bg-slate-100 text-slate-600"
: "bg-violet-50 text-violet-600"
}`}
>
{devlet ? "Devlet" : "Vakıf"}
</span>
) : null}
<span className="truncate">
{p?.universite ? (
<Link
href={`/universite/${uniSayfaSlug(p.universite)}`}
className="hover:text-primary hover:underline"
title={`${p.universite} taban puanları sayfası`}
onClick={(e) => e.stopPropagation()}
>
{p.universite}
</Link>
) : null}
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
</span>
</>
) : (
<span className="text-slate-400">Veri eksik</span>
)}
</span>
<span className="hidden items-center gap-1.5 text-xs font-medium xl:flex">
</span>
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<ProgramSiraGecmisi program={seri} />
</TableCell>
<TableCell className="text-xs font-medium">
{analizKilitli ? (
<>
<Lock className="size-3.5 text-slate-400" aria-hidden />
<span className="text-slate-500">Pakette</span>
</>
<span className="inline-flex items-center gap-1.5 text-slate-500">
<Lock className="size-3.5 shrink-0 text-slate-400" aria-hidden />
<span className="sr-only sm:not-sr-only">Pakette</span>
</span>
) : (
<>
<span className={`size-2.5 rounded-full ${stil.nokta}`} aria-hidden />
<span>{RISK_ETIKET[risk]}</span>
</>
<span className="inline-flex items-center gap-1.5">
<span
className={`size-2.5 shrink-0 rounded-full ${stil.nokta}`}
aria-hidden
/>
<span className="sr-only sm:not-sr-only">
{RISK_ETIKET[risk]}
</span>
</span>
)}
</span>
<ChevronDown
className={`size-4 shrink-0 text-slate-400 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
aria-hidden
/>
</button>
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<ProgramTabanTrendi program={seri} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
</span>
</TableCell>
<TableCell className="pr-4 text-right">
<button
type="button"
aria-expanded={acikMi}
onClick={(e) => {
e.stopPropagation();
tiklandi(t.sira);
}}
className="inline-flex size-8 min-h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-primary hover:bg-primary/5 hover:text-primary active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronDown
className={`size-4 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
aria-hidden
/>
<span className="sr-only">
{p?.isim ?? t.programId} Yapay Zeka değerlendirmesi
</span>
</button>
</TableCell>
</TableRow>
{acikMi ? (
<div className="border-t border-slate-100 bg-slate-50/50 px-4 py-4">
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={SUTUN_SAYISI}
className="whitespace-normal bg-slate-50/60 px-4 py-4"
>
{analizKilitli ? (
<>
<Lock className="size-3.5" aria-hidden />
<span className="font-medium">Kişisel analiz pakette</span>
</>
) : (
<>
<stil.icon className="size-3.5" aria-hidden />
<span className="font-medium">
{DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]}
</span>
</>
)}
{p?.unitur ? <span>· {p.unitur}</span> : null}
</div>
<dl className="grid gap-2 text-sm sm:grid-cols-3">
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
20212025 taban trendi
</dt>
<dd className="mt-1 text-slate-700">
{p?.siraGecmisi ? (
<TrendCizgisi gecmis={p.siraGecmisi} />
) : (
"Yeterli veri yok."
)}
</dd>
</div>
{analizKilitli ? (
<div className="flex flex-col justify-center rounded-lg border border-dashed border-slate-300 bg-white p-3 sm:col-span-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<Lock className="size-3.5" aria-hidden />
Yapay Zeka karar desteği
</dt>
<dd className="mt-1 text-sm text-slate-600">
Bu bölümün neden listende olduğunu ve sıralamana özel yerleşme
riskini paketle gör.
</dd>
</div>
) : (
<>
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Neden listede?
</dt>
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
<span className="font-medium">Kişisel analiz pakette</span>
{p?.unitur ? <span>· {p.unitur}</span> : null}
</div>
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Risk ve Yapay Zeka yorumu
</dt>
<dd className="mt-1 text-slate-700">
{t.riskNotu} {t.trendOzeti}
</dd>
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-3">
<p className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<Lock className="size-3.5" aria-hidden />
Yapay Zeka karar desteği
</p>
<p className="mt-1 text-sm text-slate-600">
Bu bölümün neden listende olduğunu ve sıralamana özel
yerleşme riskini paketle gör.
</p>
</div>
</>
) : (
<RaporSatirDetayi
dilim={t.dilim}
risk={risk}
unitur={p?.unitur}
gerekce={t.gerekce}
riskNotu={t.riskNotu}
trendOzeti={t.trendOzeti}
/>
)}
</dl>
{onHaritadaGor && p?.il ? (
<button
type="button"
onClick={() => onHaritadaGor(p.il, t.sira)}
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium text-primary transition-colors duration-200 hover:text-primary/80"
>
<MapPin className="size-3.5" aria-hidden />
Haritada gör
</button>
) : null}
</div>
{onHaritadaGor && p?.il ? (
<button
type="button"
onClick={() => onHaritadaGor(p.il, t.sira)}
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium text-primary transition-colors duration-200 hover:text-primary/80"
>
<MapPin className="size-3.5" aria-hidden />
Haritada gör
</button>
) : null}
</TableCell>
</TableRow>
) : null}
</li>
</Fragment>
);
};
return (
<div>
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<h2 className="font-heading text-lg font-bold">24 tercihin ve taban verileri</h2>
<p className="mt-1 text-xs text-slate-500">
Satıra dokunarak 20212025 trendini inceleyebilirsin.
</p>
</div>
{kilitIdx < rapor.tercihler.length ? (
<p className="text-xs font-medium text-emerald-700">
Programlar ve ham veriler ücretsiz
</p>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs font-medium">
{(
[
["hayal", "bg-red-500"],
["dengeli", "bg-amber-500"],
["garanti", "bg-emerald-500"],
] as const
).map(([dilim, nokta]) => (
<span
key={dilim}
className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-slate-700"
>
<span className={`size-2 rounded-full ${nokta}`} aria-hidden />
{dilimSayilari[dilim] ?? 0} {DILIM_ETIKET[dilim].toLocaleLowerCase("tr-TR")}
</span>
))}
<span className="text-slate-400">
Liste hayal diliminden başlar; kırmızı satırlar plana dahildir.
</span>
</div>
{kilitIdx < rapor.tercihler.length ? (
<p className="text-right text-xs font-medium text-emerald-700">
Programlar ve ham veriler ücretsiz
</p>
) : null}
{tekIl ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Listenin 24 tercihi de tek şehirde (
{tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak
için birkaç farklı şehir eklemeyi düşünebilirsin.
{tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak için
birkaç farklı şehir eklemeyi düşünebilirsin.
</p>
) : null}
<div className="mt-4 hidden grid-cols-[auto_minmax(220px,1fr)_92px_92px_112px_82px_auto] gap-x-3 px-4 text-[11px] font-semibold uppercase tracking-wide text-slate-500 xl:grid">
<span className="w-7">#</span>
<span>Program</span>
<span className="text-right">2024 taban</span>
<span className="text-right">2025 taban</span>
<span>Ham trend</span>
<span>Yapay Zeka risk</span>
<span className="w-4" />
<div className="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8 pl-4 pr-0">#</TableHead>
<TableHead>Program</TableHead>
<TableHead className="hidden text-right md:table-cell">
Son 5 yıl
</TableHead>
<TableHead>
<span className="hidden sm:inline">Yapay Zeka </span>Risk
</TableHead>
<TableHead className="text-right">Taban sıra</TableHead>
<TableHead className="w-12 pr-4 text-right">
<span className="sr-only">Yapay Zeka değerlendirmesi</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rapor.tercihler.map((t, index) => (
<Fragment key={t.sira}>
{index === kilitIdx && kilitOverlay ? (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={SUTUN_SAYISI}
className="whitespace-normal border-y border-orange-200 bg-orange-50/70 p-5"
>
{kilitOverlay}
</TableCell>
</TableRow>
) : null}
{satir(t, index)}
</Fragment>
))}
</TableBody>
</Table>
</div>
<ol className="mt-2 flex flex-col gap-2">
{rapor.tercihler.map((t, index) => (
<Fragment key={t.sira}>
{index === kilitIdx && kilitOverlay ? (
<li className="my-2 list-none rounded-2xl border border-orange-200 bg-orange-50/70 p-5">
{kilitOverlay}
</li>
) : null}
{satir(t, index)}
</Fragment>
))}
</ol>
</div>
);
}

View File

@@ -86,7 +86,7 @@ export function Sayfalama({
{sayfa > 1
? gecis(
sayfa - 1,
"inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
"inline-flex h-9 items-center gap-1 rounded-full border border-slate-200 bg-white px-3.5 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
<>
<ChevronLeft className="size-4" aria-hidden />
Önceki
@@ -107,8 +107,8 @@ export function Sayfalama({
gecis(
n,
n === sayfa
? "inline-flex h-9 min-w-9 items-center justify-center rounded-lg bg-primary px-2 text-sm font-semibold text-primary-foreground"
: "inline-flex h-9 min-w-9 items-center justify-center rounded-lg border border-slate-200 bg-white px-2 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
? "inline-flex h-9 min-w-9 items-center justify-center rounded-full bg-primary px-2 text-sm font-semibold text-primary-foreground"
: "inline-flex h-9 min-w-9 items-center justify-center rounded-full border border-slate-200 bg-white px-2 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
n,
{ aktif: n === sayfa },
)
@@ -117,7 +117,7 @@ export function Sayfalama({
{sayfa < toplamSayfa
? gecis(
sayfa + 1,
"inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
"inline-flex h-9 items-center gap-1 rounded-full border border-slate-200 bg-white px-3.5 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
<>
Sonraki
<ChevronRight className="size-4" aria-hidden />

View File

@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-full border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
@@ -22,16 +22,14 @@ const buttonVariants = cva(
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
"h-9 gap-1.5 px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 px-3 text-[0.8rem] has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-12 gap-2 px-7 text-base has-data-[icon=inline-end]:pr-6 has-data-[icon=inline-start]:pl-6",
icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7",
"icon-lg": "size-12",
},
},
defaultVariants: {

View File

@@ -28,7 +28,7 @@ function PromptSuggestion({
<Button
variant={variant || "outline"}
size={size || "lg"}
className={cn("rounded-full", className)}
className={cn(className)}
{...props}
>
{children}
@@ -42,7 +42,7 @@ function PromptSuggestion({
variant={variant || "ghost"}
size={size || "sm"}
className={cn(
"w-full cursor-pointer justify-start rounded-xl py-2",
"w-full cursor-pointer justify-start py-2",
"hover:bg-accent",
className
)}
@@ -63,7 +63,7 @@ function PromptSuggestion({
variant={variant || "ghost"}
size={size || "sm"}
className={cn(
"w-full cursor-pointer justify-start gap-0 rounded-xl py-2",
"w-full cursor-pointer justify-start gap-0 py-2",
"hover:bg-accent",
className
)}

View File

@@ -8,6 +8,7 @@ import {
programEtkinSira,
} from "@/components/program-liste-verileri";
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
import { ProgramNetleriButonu } from "@/components/program-netleri";
import {
Table,
TableBody,
@@ -56,8 +57,8 @@ export function UniversiteProgramTablosu({
Kontenjan
</TableHead>
<TableHead className="text-right">Taban sıra</TableHead>
<TableHead className="w-14 text-right">
<span className="sr-only">Listeye ekle</span>
<TableHead className="w-24 text-right">
<span className="sr-only">Netler ve listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
@@ -108,11 +109,18 @@ export function UniversiteProgramTablosu({
</span>
</TableCell>
<TableCell className="pr-3 text-right">
<ProgramEkleButonu
program={p}
kaynak="universite"
className="size-8 min-h-8 min-w-8"
/>
<span className="inline-flex items-center gap-1.5">
<ProgramNetleriButonu
programId={p.id}
isim={p.isim.trim()}
className="size-8 min-h-8 min-w-8"
/>
<ProgramEkleButonu
program={p}
kaynak="universite"
className="size-8 min-h-8 min-w-8"
/>
</span>
</TableCell>
</TableRow>
);

View File

@@ -0,0 +1,54 @@
"use client";
import { useEffect, useRef, type RefObject } from "react";
import { olay, type OlayAdi, type Ozellikler } from "@/lib/analitik";
/**
* Bir elemanın viewport'a ilk girişinde analitik olayı atan ref hook'u.
* Gösterim olayları (hazir_panel_goruntulendi, kilit_goruntulendi vb.)
* oturum başına en fazla 1 kez sayılmalı; tekilleştirme sessionStorage
* anahtarıyla yapılır. IntersectionObserver yoksa olay hiç atılmaz —
* "görüntülendi" ölçümünde görünürlüğü doğrulayamadığımız cihazı saymak
* veriyi şişirir.
*/
export function useGorunumOlayi<T extends HTMLElement = HTMLElement>(
ad: OlayAdi,
ozellikler?: Ozellikler,
/** Aynı olayın farklı yüzeyleri için tekilleştirme eki ("satir", "kart"). */
anahtarEki?: string,
): RefObject<T | null> {
const ref = useRef<T>(null);
// Özellikler her render sonrası tazelenir; observer effect'i yalnızca
// ad/anahtar değişiminde kurulur (deps'e obje koymamak için ref).
const ozelliklerRef = useRef(ozellikler);
useEffect(() => {
ozelliklerRef.current = ozellikler;
});
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const anahtar = `kolaytercih.olay.${ad}${anahtarEki ? `.${anahtarEki}` : ""}`;
try {
if (window.sessionStorage.getItem(anahtar)) return;
} catch {
// Storage engelliyse tekilleştirmeden vazgeç, olay yine atılabilir
}
const io = new IntersectionObserver((entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
io.disconnect();
try {
window.sessionStorage.setItem(anahtar, "1");
} catch {
// yut
}
olay(ad, ozelliklerRef.current);
});
io.observe(el);
return () => io.disconnect();
}, [ad, anahtarEki]);
return ref;
}

View File

@@ -3,6 +3,7 @@ import { Coins } from "lucide-react";
import { getCurrentUser } from "@/lib/session";
import { Button } from "@/components/ui/button";
import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
import { ListeSenkron } from "@/components/manuel-liste/liste-senkron";
import { RybbitIdentify } from "@/components/rybbit-identify";
import { SignOutButton } from "./sign-out-button";
@@ -29,6 +30,7 @@ export async function UserNav() {
return (
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
<RybbitIdentify kullaniciId={null} />
<ListeSenkron userId={null} />
<ListemButonu />
<Button
asChild
@@ -43,6 +45,7 @@ export async function UserNav() {
return (
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
<RybbitIdentify kullaniciId={u.id} eposta={u.email} isim={u.name} />
<ListeSenkron userId={u.id} />
<ListemButonu />
<Link
href="/paket"

15
src/instrumentation.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* Sunucu açılışında bir kez çalışır (Next instrumentation konvansiyonu).
* Kredi-bitti hatırlatma taramasını başlatır — tek, sürekli çalışan
* container'da harici cron yerine in-process zamanlayıcı yeterli.
*/
export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
if (process.env.NEXT_PHASE === "phase-production-build") return;
// Migration akışı olmadığından sonradan eklenen tablolar açılışta güvenceye
// alınır (additive DDL, idempotent) — mevcut prod volume'ü seed'den kopyalanmaz.
const { semayiGuvenceyeAl } = await import("./lib/appdb/sema-guvence");
await semayiGuvenceyeAl();
const { krediHatirlatmaBaslat } = await import("./lib/kredi-hatirlatma");
krediHatirlatmaBaslat();
}

View File

@@ -19,11 +19,18 @@ export type OlayAdi =
| "liste_olusturuldu"
| "liste_revize"
| "program_eklendi"
| "netler_goruntulendi"
| "danisman_sorusu"
| "katalog_arama"
| "odeme_baslatildi";
| "odeme_baslatildi"
| "hazir_panel_goruntulendi"
| "giris_cta_tiklandi"
| "kilit_goruntulendi"
| "kredi_bitti"
| "tadimlik_goruntulendi"
| "kaydet_banner_goruntulendi";
type Ozellikler = Record<string, string | number>;
export type Ozellikler = Record<string, string | number>;
interface RybbitApi {
event: (ad: string, ozellikler?: Ozellikler) => void;

View File

@@ -18,6 +18,13 @@ export const user = sqliteTable("user", {
image: text("image"),
creditBalance: integer("credit_balance").notNull().default(0),
hasPaket: integer("has_paket", { mode: "boolean" }).notNull().default(false),
// Kredi-bitti hatırlatma durum makinesi (kuruldu → gönderildi):
// ilk INSUFFICIENT anı; kredi yüklenince sıfırlanır (hatırlatma iptal).
krediBittiAt: integer("kredi_bitti_at", { mode: "timestamp" }),
// Hatırlatma e-postası bir kez gönderilir, bu alan hiç sıfırlanmaz.
krediHatirlatmaGonderildiAt: integer("kredi_hatirlatma_gonderildi_at", {
mode: "timestamp",
}),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
@@ -145,3 +152,47 @@ export const reports = sqliteTable("reports", {
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
// Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları.
// Anahtar: sıra kovası × puan türü × kategori ("genel" = kategorisiz fallback).
// Anonim istek LLM'e asla gitmez; bu tablo salt-okunur servis edilir.
// NOT: Yeni tablo/kolon eklerken sema-guvence.ts'teki DDL de güncellenmeli.
export const tadimlikHavuzu = sqliteTable(
"tadimlik_havuzu",
{
id: text("id").primaryKey(),
kovaSlug: text("kova_slug").notNull(),
tur: text("tur", { enum: ["say", "ea", "soz", "dil", "tyt"] }).notNull(),
kategoriSlug: text("kategori_slug").notNull(),
programId: text("program_id").notNull(),
dilim: text("dilim", { enum: ["hayal", "dengeli", "garanti"] }).notNull(),
gerekce: text("gerekce").notNull(),
riskNotu: text("risk_notu").notNull(),
trendOzeti: text("trend_ozeti").notNull(),
// Üretim anındaki program snapshot'ı (isim, universite, il, unitur,
// efektifSira, siraGecmisi) — render yokatlas sorgusu gerektirmesin diye.
program: text("program", { mode: "json" }).notNull(),
// Üretimde kullanılan temsili aday sırası (kovanın orta noktası).
ornekSira: integer("ornek_sira").notNull(),
uretimAt: integer("uretim_at", { mode: "timestamp" }).notNull(),
},
(t) => [
uniqueIndex("tadimlik_anahtar").on(t.kovaSlug, t.tur, t.kategoriSlug),
],
);
// Manuel liste + sihirbaz profili — girişte localStorage'dan merge edilir
// (çakışmada client kazanır, 24 sınırı korunur). reports ile aynı upsert deseni.
export const savedLists = sqliteTable("saved_lists", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.unique()
.references(() => user.id),
// ManuelTercih[] (≤24, sıra korunur)
items: text("items", { mode: "json" }).notNull(),
// TercihProfili | null
profil: text("profil", { mode: "json" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});

View File

@@ -0,0 +1,45 @@
import { sql } from "drizzle-orm";
import { appDb } from "./index";
/**
* Prod volume'ündeki app.db'ye sonradan eklenen tabloları güvenceye alır.
*
* Projede drizzle migration akışı yok: şema lokalde `pnpm db:push` ile
* uygulanıp data/app.db seed olarak taşınıyor; ama docker-entrypoint.sh
* app.db'yi yalnızca volume BOŞKEN kopyalar — mevcut prod volume'üne yeni
* tablolar başka türlü gelmez. Burası sunucu açılışında (instrumentation)
* bir kez koşar; yalnızca additive DDL içerir, idempotenttir.
*
* NOT: schema.ts'e tablo/kolon eklendiğinde buradaki DDL de güncellenmeli.
*/
export async function semayiGuvenceyeAl(): Promise<void> {
await appDb.run(sql`
CREATE TABLE IF NOT EXISTS tadimlik_havuzu (
id TEXT PRIMARY KEY,
kova_slug TEXT NOT NULL,
tur TEXT NOT NULL,
kategori_slug TEXT NOT NULL,
program_id TEXT NOT NULL,
dilim TEXT NOT NULL,
gerekce TEXT NOT NULL,
risk_notu TEXT NOT NULL,
trend_ozeti TEXT NOT NULL,
program TEXT NOT NULL,
ornek_sira INTEGER NOT NULL,
uretim_at INTEGER NOT NULL
)
`);
await appDb.run(
sql`CREATE UNIQUE INDEX IF NOT EXISTS tadimlik_anahtar ON tadimlik_havuzu (kova_slug, tur, kategori_slug)`,
);
await appDb.run(sql`
CREATE TABLE IF NOT EXISTS saved_lists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL UNIQUE REFERENCES user(id),
items TEXT NOT NULL,
profil TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}

View File

@@ -4,6 +4,7 @@ import { magicLink } from "better-auth/plugins";
import { appDb, schema } from "./appdb";
import { grantCredits, DENEME_KREDISI } from "./credits";
import { magicLinkEpostasi } from "./eposta";
import { epostaGonder } from "./eposta-gonder";
async function sendMagicLinkEmail(email: string, url: string) {
if (process.env.NODE_ENV !== "production") {
@@ -16,25 +17,9 @@ async function sendMagicLinkEmail(email: string, url: string) {
console.log(`\n[giris] Magic link for ${email}:\n${url}\n`);
return;
}
const { Resend } = await import("resend");
const resend = new Resend(process.env.RESEND_API_KEY);
const { subject, html, text } = magicLinkEpostasi(url);
const { error } = await resend.emails.send({
from: process.env.EMAIL_FROM ?? "KolayTercih <onboarding@resend.dev>",
to: email,
subject,
html,
// Düz metin alternatifi spam puanını düşürür ve HTML'i engelleyen
// istemcilerde bağlantının okunabilir kalmasını sağlar.
text,
});
// Resend SDK hata fırlatmaz, { data, error } döndürür. Kontrol etmezsek
// doğrulanmamış domain/kota hatasında kullanıcı "gönderildi" ekranını
// görür ama mail hiç çıkmaz — fırlatarak better-auth'a hata döndürüyoruz.
if (error) {
console.error(`[giris] Magic link gönderilemedi (${email}):`, error);
throw new Error(`Resend: ${error.name}${error.message}`);
}
// Gönderim hatası better-auth'a fırlatılır — aksi halde kullanıcı
// "gönderildi" ekranını görür ama mail hiç çıkmaz.
await epostaGonder(email, magicLinkEpostasi(url));
}
export const auth = betterAuth({

View File

@@ -1,4 +1,4 @@
import { and, eq, gte, sql } from "drizzle-orm";
import { and, eq, gte, isNull, sql } from "drizzle-orm";
import { appDb, schema } from "./appdb";
const { user, creditLedger, chatMessages } = schema;
@@ -139,6 +139,8 @@ export async function grantCredits(opts: {
.set({
creditBalance: sql`${user.creditBalance} + ${opts.delta}`,
...(opts.setHasPaket ? { hasPaket: true } : {}),
// Kredi gelince bekleyen kredi-bitti hatırlatması iptal olur
krediBittiAt: null,
updatedAt: new Date(),
})
.where(eq(user.id, opts.userId));
@@ -150,3 +152,26 @@ export async function grantCredits(opts: {
throw err;
}
}
/**
* Kredi-bitti anını işaretler (48 saatlik hatırlatma e-postasının saati).
* Yalnızca ilk bitişte yazar (kolon doluysa no-op) ve hatırlatma zaten
* gönderildiyse yeniden kurmaz — e-posta ömür boyu 1 kez. Hata yolunda
* çağrıldığı için asla throw etmez.
*/
export async function krediBittiIsaretle(userId: string): Promise<void> {
try {
await appDb
.update(user)
.set({ krediBittiAt: new Date() })
.where(
and(
eq(user.id, userId),
isNull(user.krediBittiAt),
isNull(user.krediHatirlatmaGonderildiAt),
),
);
} catch (err) {
console.error(`[kredi] kredi-bitti işaretlenemedi (${userId}):`, err);
}
}

View File

@@ -189,6 +189,47 @@ export function searchByRank(
};
}
/** netler tablosunun bir yılı: yerleşen son kişinin ders bazlı netleri. */
export type ProgramNetSatiri = {
yil: number;
puanTuru: string | null;
tabanPuan: number | null;
obp: number | null;
tytTrkNet: number | null;
tytSosNet: number | null;
tytMatNet: number | null;
tytFenNet: number | null;
aytMatNet: number | null;
aytFizNet: number | null;
aytKimNet: number | null;
aytBioNet: number | null;
aytTdeNet: number | null;
aytTrh1Net: number | null;
aytCog1Net: number | null;
aytTrh2Net: number | null;
aytCog2Net: number | null;
aytDinNet: number | null;
aytFelNet: number | null;
ydtYdilNet: number | null;
};
/**
* Programa yerleşen son kişinin yıl bazlı netleri, yeniden eskiye sıralı.
* Veri scripts/detay.ts ile YÖK Atlas /api/netler/search'ten doldurulur.
*/
export function netlerGetir(programId: string): ProgramNetSatiri[] {
return getDb()
.prepare(
`SELECT yil, puanTuru, tabanPuan, obp,
tytTrkNet, tytSosNet, tytMatNet, tytFenNet,
aytMatNet, aytFizNet, aytKimNet, aytBioNet,
aytTdeNet, aytTrh1Net, aytCog1Net, aytTrh2Net, aytCog2Net,
aytDinNet, aytFelNet, ydtYdilNet
FROM netler WHERE program_id = ? ORDER BY yil DESC`
)
.all(programId) as ProgramNetSatiri[];
}
export type SihirbazFacetleri = {
kategoriler: { ad: string; adet: number }[];
iller: { il: string; adet: number }[];

36
src/lib/eposta-gonder.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { EpostaIcerik } from "./eposta";
/**
* Ortak e-posta göndericisi (Resend). Şablonlar eposta.ts'te, gönderim
* burada — magic link (auth.ts) ve kredi hatırlatması aynı yoldan çıkar.
* RESEND_API_KEY yoksa dev fallback: içerik terminale yazılır.
*/
export async function epostaGonder(
to: string,
icerik: EpostaIcerik,
): Promise<void> {
if (!process.env.RESEND_API_KEY) {
console.log(
`\n[eposta] (dev) ${to} adresine gönderilecekti — ${icerik.subject}\n${icerik.text}\n`,
);
return;
}
const { Resend } = await import("resend");
const resend = new Resend(process.env.RESEND_API_KEY);
const { error } = await resend.emails.send({
from: process.env.EMAIL_FROM ?? "KolayTercih <onboarding@resend.dev>",
to,
subject: icerik.subject,
html: icerik.html,
// Düz metin alternatifi spam puanını düşürür ve HTML'i engelleyen
// istemcilerde içeriğin okunabilir kalmasını sağlar.
text: icerik.text,
});
// Resend SDK hata fırlatmaz, { data, error } döndürür. Kontrol etmezsek
// doğrulanmamış domain/kota hatası sessizce yutulur — fırlatarak çağıran
// tarafın (better-auth, hatırlatma taraması) haberdar olmasını sağlıyoruz.
if (error) {
console.error(`[eposta] Gönderilemedi (${to}):`, error);
throw new Error(`Resend: ${error.name}${error.message}`);
}
}

View File

@@ -61,9 +61,12 @@ function baslik(): string {
function kabuk({
onizleme,
govde,
baslikEtiketi,
}: {
onizleme: string;
govde: string;
/** <title> içeriği — genelde konuyla aynı. */
baslikEtiketi: string;
}): string {
return `<!doctype html>
<html lang="tr">
@@ -71,7 +74,7 @@ function kabuk({
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<title>Merhaba, giriş bağlantını gönderdik</title>
<title>${kacir(baslikEtiketi)}</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,300;12..96,600&family=Cal+Sans&display=swap" rel="stylesheet">
</head>
@@ -141,6 +144,7 @@ export function magicLinkEpostasi(url: string): EpostaIcerik {
html: kabuk({
onizleme: "Giriş bağlantın 5 dakika geçerli.",
govde,
baslikEtiketi: "Merhaba, giriş bağlantını gönderdik",
}),
text: [
"Merhaba, giriş bağlantını gönderdik",
@@ -153,3 +157,49 @@ export function magicLinkEpostasi(url: string): EpostaIcerik {
].join("\n"),
};
}
/**
* Kredi bittikten 48 saat sonra giden tek seferlik hatırlatma. KVKK sınıfı
* netleşene dek yalnızca işlemsel dil: hesaptaki mevcut listeyi hatırlatır,
* ödeme ancak ikincil bir cümlede geçer (bkz. PRD İP-A / A2).
*/
export function krediHatirlatmaEpostasi(): EpostaIcerik {
const listemUrl = `${SITE_URL}/listem`;
const konu = "Listen seni bekliyor — açık satırların hazır";
const govde = `
<h1 style="margin:0 0 12px;font-family:${BASLIK_TIPI};font-size:22px;line-height:1.35;font-weight:700;color:${LACIVERT};">
Listen seni bekliyor
</h1>
<p style="margin:0 0 24px;">
Yapay zekânın senin sıralamana göre kurduğu tercih listesi hesabında
duruyor; gerekçeleri ve risk notlarıyla
<strong style="color:${LACIVERT};">açık satırların hazır</strong>.
Kaldığın yerden devam edebilirsin.
</p>
${buton(listemUrl, "Listeni gör")}
<p style="margin:24px 0 0;font-size:13px;">
Listenin tamamını açmak ya da danışmana soru sormak istersen kredi
yükleyebilirsin — listen ve sohbet geçmişin olduğu gibi durur.
</p>`;
return {
subject: konu,
html: kabuk({
onizleme: "Yapay zekânın kurduğu listen hesabında duruyor.",
govde,
baslikEtiketi: konu,
}),
text: [
"Listen seni bekliyor",
"",
"Yapay zekânın senin sıralamana göre kurduğu tercih listesi hesabında",
"duruyor; gerekçeleri ve risk notlarıyla açık satırların hazır.",
"",
"Listeni görmek için:",
listemUrl,
"",
"Listenin tamamını açmak ya da danışmana soru sormak istersen kredi",
"yükleyebilirsin — listen ve sohbet geçmişin olduğu gibi durur.",
].join("\n"),
};
}

85
src/lib/harita-pinler.ts Normal file
View File

@@ -0,0 +1,85 @@
// Rapordan Türkiye haritası pinleri türetir — /sonuc ve /listem ortak kullanır.
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { HaritaPin } from "@/components/tercih-haritasi";
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
import { dilimdenRisk, riskHesapla, type RiskSeviyesi } from "@/lib/risk";
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
guvenli: 0,
"az-riskli": 1,
riskli: 2,
};
/**
* Rapordan harita pinleri türetir. Açık satırlar üniversite bazında gruplanır
* (kampüs konumunda, adıyla); kilitli satırlar il başına tek adsız "?" pinine
* toplanır — maskeli veride üniversite adı zaten client'a inmediği için
* harita paywall'ı delemez. Haritada karşılığı olmayan tercihler sayılır.
*/
export function pinleriTuret(
rapor: RaporSonuc,
adaySira: number,
kilitliBaslangic: number,
): { pinler: HaritaPin[]; haritaDisi: number } {
const gruplar = new Map<string, HaritaPin>();
let haritaDisi = 0;
rapor.tercihler.forEach((t, i) => {
const p = rapor.programlar[t.programId];
if (!p) return;
const risk = riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim);
const kilitli = i >= kilitliBaslangic;
if (kilitli) {
const konum = ilKonum(p.il);
if (!konum) {
haritaDisi += 1;
return;
}
const key = `kilit|${p.il}`;
const mevcut = gruplar.get(key);
if (mevcut) {
mevcut.tercihSiralari.push(t.sira);
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
} else {
// Açık pinlerle üst üste binmesin diye il merkezinin hafif altına
gruplar.set(key, {
id: key,
universite: "",
il: p.il,
x: konum.x,
y: konum.y + 9,
tercihSiralari: [t.sira],
risk,
kilitli: true,
});
}
return;
}
const konum = uniKonum(p.universite, p.il);
if (!konum) {
haritaDisi += 1;
return;
}
const key = `${uniAdiNormalize(p.universite)}|${p.il ?? ""}`;
const mevcut = gruplar.get(key);
if (mevcut) {
mevcut.tercihSiralari.push(t.sira);
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
} else {
gruplar.set(key, {
id: key,
universite: p.universite,
il: p.il,
x: konum.x,
y: konum.y,
tercihSiralari: [t.sira],
risk,
});
}
});
return { pinler: [...gruplar.values()], haritaDisi };
}

View File

@@ -0,0 +1,87 @@
import { and, eq, isNull, lt, lte } from "drizzle-orm";
import { appDb, schema } from "./appdb";
import { RAPOR_KREDI } from "./credits";
import { krediHatirlatmaEpostasi } from "./eposta";
import { epostaGonder } from "./eposta-gonder";
const { user } = schema;
// 48 saat sonra tek seferlik hatırlatma (PRD İP-A / A2). Env override'ları
// lokal testte akışı dakikalara sıkıştırmak için var.
const GECIKME_MS = Number(
process.env.KREDI_HATIRLATMA_GECIKME_MS ?? 48 * 60 * 60 * 1000,
);
const ARALIK_MS = Number(
process.env.KREDI_HATIRLATMA_ARALIK_MS ?? 60 * 60 * 1000,
);
/**
* Kredisi bitmiş ve 48 saattir dönmemiş kullanıcılara hatırlatma e-postası
* gönderir. Kredi yüklemek `krediBittiAt`'ı sıfırladığı (grantCredits) ve
* bakiye filtresi de burada olduğu için yalnızca hâlâ takılı olanlar seçilir.
* Gönderildi işareti e-postadan ÖNCE koşullu UPDATE ile atılır: yarışta
* yalnızca bir taraf `rowsAffected === 1` görür — çift gönderim imkânsız,
* Resend hatasında o kullanıcının hatırlatması yanar (bilinçli at-most-once).
*/
export async function krediHatirlatmaTaramasi(): Promise<void> {
const esik = new Date(Date.now() - GECIKME_MS);
const adaylar = await appDb
.select({ id: user.id, email: user.email })
.from(user)
.where(
and(
lte(user.krediBittiAt, esik),
isNull(user.krediHatirlatmaGonderildiAt),
lt(user.creditBalance, RAPOR_KREDI),
),
)
.limit(100);
for (const aday of adaylar) {
const res = await appDb
.update(user)
.set({ krediHatirlatmaGonderildiAt: new Date() })
.where(
and(eq(user.id, aday.id), isNull(user.krediHatirlatmaGonderildiAt)),
);
if (res.rowsAffected !== 1) continue; // başka instance kazandı
try {
await epostaGonder(aday.email, krediHatirlatmaEpostasi());
} catch (err) {
console.error(
`[hatirlatma] Kredi hatırlatması gönderilemedi (${aday.email}):`,
err,
);
}
}
}
/**
* Saatlik tarama zamanlayıcısı — instrumentation.ts'ten, sunucu başına bir
* kez çağrılır. globalThis guard'ı dev hot-reload'da çifte interval'i önler
* (appdb/index.ts deseni).
*/
export function krediHatirlatmaBaslat(): void {
const g = globalThis as unknown as { __krediHatirlatmaKuruldu?: boolean };
if (g.__krediHatirlatmaKuruldu) return;
g.__krediHatirlatmaKuruldu = true;
let calisiyor = false;
const tik = async () => {
if (calisiyor) return; // önceki tarama uzadıysa üst üste binme
calisiyor = true;
try {
await krediHatirlatmaTaramasi();
} catch (err) {
console.error("[hatirlatma] Tarama hatası:", err);
} finally {
calisiyor = false;
}
};
// Container yeniden başlarsa saatlik açıklık büyümesin diye kısa bir
// gecikmeyle açılış taraması da yapılır.
setTimeout(tik, 60_000);
setInterval(tik, ARALIK_MS);
}

View File

@@ -7,6 +7,12 @@ import type { RaporSonuc } from "./ai/rapor";
export const ACIK_SATIR = 3;
/**
* Maske çıktısı: kilitli uyarıların yalnızca SAYISI iner, metinleri inmez.
* RaporSchema'ya (LLM çıktısı) eklenmez — bu alan yalnızca maskede doğar.
*/
export type MaskeliRapor = RaporSonuc & { kilitliUyariSayisi?: number };
const SAHTE_GEREKCE =
"Bu tercih için kişisel gerekçe paketle açılır.";
const SAHTE_RISK =
@@ -21,7 +27,7 @@ function degerlendirmeKirp(metin: string): string {
return `${cumleler.slice(0, 2).join(" ").trim()}`;
}
export function raporMaskele(rapor: RaporSonuc): RaporSonuc {
export function raporMaskele(rapor: RaporSonuc): MaskeliRapor {
const tercihler = rapor.tercihler.map((t, i) =>
i < ACIK_SATIR
? t
@@ -36,8 +42,10 @@ export function raporMaskele(rapor: RaporSonuc): RaporSonuc {
return {
tercihler,
genelDegerlendirme: degerlendirmeKirp(rapor.genelDegerlendirme),
// Kişisel uyarılar ücretli katmandır; istemciye hiç inmez.
uyarilar: [],
// Teaser: ilk uyarıık kalır, kalan uyarıların yalnızca sayısı iner —
// kilitli metinler istemciye hiç ulaşmaz.
uyarilar: rapor.uyarilar?.slice(0, 1) ?? [],
kilitliUyariSayisi: Math.max(0, (rapor.uyarilar?.length ?? 0) - 1),
// Program adı, üniversite ve 20212025 taban sıralamaları ham veridir.
programlar: rapor.programlar,
};

View File

@@ -33,6 +33,25 @@ export function bolumBazAdi(isim: string): string {
.trim();
}
/**
* /universite/[slug] sayfa slug'ı: harita.ts'teki uniAdiNormalize ile aynı
* kural (sondaki "(İL)" eki atılır). Katalog haritası DB'deki tüm universite
* değerlerinden kurulduğu için bu slug her zaman var olan bir sayfaya çıkar.
*/
export function uniSayfaSlug(ad: string): string {
return turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim());
}
/**
* /bolum/[slug] sayfa slug'ı: katalog.ts'teki bolumSlugFromIsim ile aynı kural
* (bolumBazAdi + turkishSlugify). Bölüm haritası DB'deki tüm isim
* değerlerinden kurulduğu için bu slug da her zaman var olan bir sayfaya çıkar
* (bkz. liste-cekmecesi'ndeki bolumHref).
*/
export function bolumSayfaSlug(isim: string): string {
return turkishSlugify(bolumBazAdi(isim));
}
/** ALL-CAPS veritabanı adlarını tr-TR başlık düzenine çevirir (İSTANBUL → İstanbul). */
export function trBaslikDuzeni(s: string): string {
return s

View File

@@ -0,0 +1,83 @@
// Girişsiz yapay zekâ tadımlığının sunucu katmanı: batch üretilmiş
// (scripts/tadimlik-uret.ts) satırlar app.db'deki tadimlik_havuzu'ndan
// SEÇİLİR — anonim istek LLM'e asla gitmez, seçim her zaman sunucuda.
import { and, eq } from "drizzle-orm";
import { appDb, schema } from "./appdb";
import { turkishSlugify } from "./slug";
import type { PuanTuruKey } from "./db";
import type { Dilim } from "./rapor-havuzu";
import type { RaporSonuc } from "./ai/rapor";
/** Sihirbaz kategorisi seçilmemiş kullanıcı için kova×tür başına fallback satır. */
export const GENEL_KATEGORI = "genel";
/**
* Havuz anahtarındaki sıra kovaları. Eşikler analitik.ts'teki siraKovasi()
* ile birebir aynı tutulmalı (oradaki Türkçe etiketler DB anahtarı olamaz).
* temsiliSira: batch üretimde "bu kovadaki tipik aday" olarak kullanılan sıra.
*/
export const KOVALAR = [
{ slug: "0-10k", temsiliSira: 5_000, max: 10_000 },
{ slug: "10-50k", temsiliSira: 30_000, max: 50_000 },
{ slug: "50-100k", temsiliSira: 75_000, max: 100_000 },
{ slug: "100-250k", temsiliSira: 175_000, max: 250_000 },
{ slug: "250-500k", temsiliSira: 375_000, max: 500_000 },
{ slug: "500k-plus", temsiliSira: 750_000, max: Number.POSITIVE_INFINITY },
] as const;
export type Kova = (typeof KOVALAR)[number];
export function kovaBul(sira: number): Kova {
return KOVALAR.find((k) => sira <= k.max) ?? KOVALAR[KOVALAR.length - 1];
}
/** "Mühendislik & Teknoloji" → "muhendislik-teknoloji" (havuz anahtarı). */
export function kategoriSlugBul(kategori: string): string {
return turkishSlugify(kategori);
}
export interface TadimlikSatir {
programId: string;
dilim: Dilim;
gerekce: string;
riskNotu: string;
trendOzeti: string;
program: RaporSonuc["programlar"][string];
}
/**
* Kova × tür × kategori anahtarıyla tadımlık satırı seçer. Kategoriye özel
* satır yoksa "genel"e düşer; o da yoksa null — çağıran taraf bileşeni hiç
* render etmez (boş/generic tadımlık gösterilmez, PRD kuralı).
*/
export async function tadimlikSec(
sira: number,
tur: PuanTuruKey,
kategoriSlug?: string,
): Promise<TadimlikSatir | null> {
const kova = kovaBul(sira);
const sluglar = [
...new Set([kategoriSlug ?? GENEL_KATEGORI, GENEL_KATEGORI]),
];
for (const slug of sluglar) {
const satir = await appDb.query.tadimlikHavuzu.findFirst({
where: and(
eq(schema.tadimlikHavuzu.kovaSlug, kova.slug),
eq(schema.tadimlikHavuzu.tur, tur),
eq(schema.tadimlikHavuzu.kategoriSlug, slug),
),
});
if (satir) {
return {
programId: satir.programId,
dilim: satir.dilim,
gerekce: satir.gerekce,
riskNotu: satir.riskNotu,
trendOzeti: satir.trendOzeti,
program: satir.program as RaporSonuc["programlar"][string],
};
}
}
return null;
}