Update Next.js configuration and package dependencies
All checks were successful
Deploy / deploy (push) Successful in 9m29s

- Modified redirects in next.config.ts to change the destination for "/sohbet" to "/listem".
- Added new dependencies in package.json: marked, motion, react-markdown, remark-breaks, remark-gfm, shiki, and use-stick-to-bottom.
- Updated pnpm-lock.yaml to reflect the new package versions and dependencies.
- Removed obsolete stack files for Flutter, Nuxt, Nuxt.js, React Native, and Svelte from the UI/UX Pro Max skill data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-07-26 04:26:52 +03:00
parent 1d0328d8eb
commit 209d203fe8
59 changed files with 6599 additions and 1446 deletions

View File

@@ -8,11 +8,16 @@ import { listeOzetiCikar, type RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { secimOzeti } from "@/lib/sihirbaz";
import { PUAN_TURLERI } from "@/lib/db";
import { ACIK_SATIR } from "@/lib/rapor-maske";
const MAX_GECMIS = 12;
const MAX_MESAJ_UZUNLUK = 2000;
function sistemPromptu(opts: { params: RaporParams; listeOzeti: string }): string {
function sistemPromptu(opts: {
params: RaporParams;
listeOzeti: string;
kilitli?: boolean;
}): string {
const { params } = opts;
const secimVar =
(params.kategoriler?.length ?? 0) > 0 ||
@@ -29,6 +34,10 @@ iş imkânları, tercih sırası) net ve dürüst yanıtlamak.
KURALLAR:
- Cevaplarını adayın 24'lük listesi ve seçimleri bağlamında ver; listedeki
programlara referans verebilirsin.
- Listedeki her satırda dilim ve risk etiketi var: Güvenli (yeşil) = taban
sıralaması adayın belirgin altında, Az riskli (sarı) = adayın sıralaması
civarında, Riskli (kırmızı) = geçen yıl adayın sıralamasının üzerinde
kapandı. Risk sorularında bu etiketleri ve taban sıralarını kullan.
- ASLA yerleşme garantisi verme; "yüksek/orta/düşük ihtimal" dili kullan.
- Bilmediğin güncel veriyi uydurma; emin değilsen YÖK Atlas'a bakmasını söyle.
- Taban sıralamalarının her yıl değiştiğini gerektiğinde hatırlat.
@@ -39,6 +48,15 @@ KURALLAR:
`ADAY BİLGİSİ: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${PUAN_TURLERI[params.tur]}.`,
`ADAYIN 24'LÜK LİSTESİ:\n${opts.listeOzeti}`,
];
if (opts.kilitli) {
parcalar.push(
`ÖNEMLİ: Adayın paketi yok; listenin yalnızca ilk ${ACIK_SATIR} tercihi açık.
Sana da yalnızca bu satırlar verildi — kilitli tercihlerin içeriğini BİLMİYORSUN,
asla tahmin edip söyleme. Kilitli satırlar sorulursa sempatik biçimde tam listenin
Tercih Dönemi Paketi'yle (299 TL, /paket) açıldığını söyle; açık satırlar ve genel
tercih soruları üzerinden yardımcı olmaya devam et.`,
);
}
if (secimVar) {
parcalar.push(
`ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
@@ -140,11 +158,67 @@ export async function POST(request: NextRequest) {
}
const kullaniciMesajId = harcama.messageId!;
// Paketsiz kullanıcının prompt'una yalnızca açık satırlar girer — aksi
// halde danışman kilitli listeyi sözle sızdırabilir.
const kullanici = await appDb.query.user.findFirst({
where: eq(schema.user.id, session.user.id),
columns: { hasPaket: true },
});
const kilitli = !kullanici?.hasPaket;
const tamOzet = listeOzetiCikar(
rapor.result as RaporSonuc,
(rapor.params as RaporParams).sira,
);
const listeOzeti = kilitli
? [
...tamOzet.split("\n").slice(0, ACIK_SATIR),
`… kalan ${24 - ACIK_SATIR} tercih kilitli.`,
].join("\n")
: tamOzet;
const system = sistemPromptu({
params: rapor.params as RaporParams,
listeOzeti: listeOzetiCikar(rapor.result as RaporSonuc),
listeOzeti,
kilitli,
});
// Dev'de API key yoksa danışman mock cevap stream'ler (prod'da asla)
if (
process.env.NODE_ENV !== "production" &&
!process.env.ANTHROPIC_API_KEY &&
!process.env.ANTHROPIC_AUTH_TOKEN
) {
const mockEncoder = new TextEncoder();
const mockUserId = session.user.id;
const cevap =
`(Dev mock — ANTHROPIC_API_KEY tanımlı değil.) Sorunu aldım: "${mesaj.slice(0, 120)}". ` +
`Gerçek danışman burada listen ve seçimlerin bağlamında yanıt verecek. ` +
`${SORUMLULUK_REDDI}`;
const mockReadable = new ReadableStream({
async start(controller) {
for (const parca of cevap.split(/(?<= )/)) {
controller.enqueue(mockEncoder.encode(parca));
await new Promise((r) => setTimeout(r, 25));
}
await appDb.insert(schema.chatMessages).values({
id: crypto.randomUUID(),
userId: mockUserId,
role: "assistant",
content: cevap,
createdAt: new Date(),
});
controller.close();
},
});
return new Response(mockReadable, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
},
});
}
let client;
try {
client = getAnthropic();

View File

@@ -0,0 +1,61 @@
"use client";
import { TextLoop } from "@/components/core/text-loop";
// Sonuç sayfasından gelen funnel'da dönen sempatik mesajlar.
// Kısa tutulmalı — başlıkta tek satır, taşma yok.
const FUNNEL_MESAJLARI = [
"1 adım kaldı tercihine",
"Listen seni bekliyor 👀",
"24 tercih, hepsi sana göre",
"5 kredin hazır, ya sen?",
];
/**
* Giriş sayfası başlığı. Kullanıcı sihirbazdan (callback /sonuc'a dönüyorsa)
* geldiyse TextLoop'la funnel mesajları döner; diğer girişlerde sabit başlık.
*/
export function GirisBaslik({ funnel }: { funnel: boolean }) {
if (!funnel) {
return <h1 className="font-heading text-2xl font-bold">Giriş yap</h1>;
}
return (
<h1 className="font-heading text-2xl font-bold">
<TextLoop
className="overflow-y-clip"
interval={2.5}
transition={{
type: "spring",
stiffness: 900,
damping: 80,
mass: 10,
}}
variants={{
initial: {
y: 20,
rotateX: 90,
opacity: 0,
filter: "blur(4px)",
},
animate: {
y: 0,
rotateX: 0,
opacity: 1,
filter: "blur(0px)",
},
exit: {
y: -20,
rotateX: -90,
opacity: 0,
filter: "blur(4px)",
},
}}
>
{FUNNEL_MESAJLARI.map((mesaj) => (
<span key={mesaj}>{mesaj}</span>
))}
</TextLoop>
</h1>
);
}

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { GirisForm } from "./giris-form";
import { GirisBaslik } from "./giris-baslik";
export const metadata: Metadata = {
title: "Giriş — KolayTercih",
@@ -19,14 +20,28 @@ export default async function GirisPage({
const session = await getSession();
if (session) redirect(callbackURL);
// Sihirbaz funnel'ı: seçimler yapıldı, listeye giriş kaldı
const funnel = callbackURL.startsWith("/sonuc");
return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-4 py-16">
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<h1 className="font-heading text-2xl font-bold">Giriş yap</h1>
<GirisBaslik funnel={funnel} />
<p className="mt-2 text-sm text-slate-600">
Kayıt olduğunda <span className="font-semibold">5 deneme kredisi</span>{" "}
hesabına tanımlanır AI danışmana ücretsiz soru sorabilirsin.
{funnel ? (
<>
Seçimlerin hazır giriş yap,{" "}
<span className="font-semibold">5 deneme kredinle</span> listen
hemen üretilsin.
</>
) : (
<>
Kayıt olduğunda{" "}
<span className="font-semibold">5 deneme kredisi</span> hesabına
tanımlanır AI danışmana ücretsiz soru sorabilirsin.
</>
)}
</p>
<GirisForm callbackURL={callbackURL} />
</div>

View File

@@ -117,6 +117,33 @@
--sidebar-ring: oklch(0.556 0 0);
}
/* TypingAnimation imleci (animate-blink-cursor utility'si) */
@theme {
--animate-blink-cursor: blink-cursor 1s step-end infinite;
@keyframes blink-cursor {
50% {
opacity: 0;
}
}
}
/* Ödeme dönüşünde kilidi açılan listenin blur çözülme animasyonu */
@keyframes kt-unblur {
from {
filter: blur(12px);
opacity: 0.4;
}
to {
filter: blur(0);
opacity: 1;
}
}
.kt-unblur {
animation: kt-unblur 0.9s ease-out both;
}
@layer base {
* {
@apply border-border outline-ring/50;

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Outfit, Work_Sans, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { SiteHeader } from "@/components/site-header";
import { ListeCekmecesi } from "@/components/manuel-liste/liste-cekmecesi";
import { HeaderGate } from "@/components/header-gate";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
import "./globals.css";
@@ -59,6 +60,8 @@ export default function RootLayout({
<SiteHeader />
</HeaderGate>
{children}
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
<ListeCekmecesi />
<Toaster position="top-center" />
</body>
</html>

121
src/app/listem/page.tsx Normal file
View File

@@ -0,0 +1,121 @@
import type { Metadata } from "next";
import Link from "next/link";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { FileText, PencilLine, Sparkles } from "lucide-react";
import { verifySession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { PUAN_TURLERI } from "@/lib/db";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { Button } from "@/components/ui/button";
import { Lock } from "lucide-react";
import { RaporListesi } from "@/components/rapor-listesi";
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
import { SohbetClient } from "./sohbet-client";
export const metadata: Metadata = {
title: "Listem — KolayTercih",
};
export default async function ListemPage() {
await verifySession("/listem");
const user = await getCurrentUser();
if (!user) redirect("/giris?callback=/listem");
const satir = await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, user.id),
});
// Liste yoksa önce sihirbazdan oluşturulmalı
if (!satir?.result) {
return (
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
<Sparkles className="size-10 text-primary" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold">
Henüz bir listen yok
</h1>
<p className="mt-3 text-slate-600">
Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni
kur; sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin.
</p>
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white hover:bg-orange-600"
>
<Link href="/">Sıralamanı gir, listeni kur</Link>
</Button>
</main>
);
}
// Paketsize giden rapor burada da maskelenir (bkz. rapor-maske)
const rapor = user.hasPaket
? (satir.result as RaporSonuc)
: raporMaskele(satir.result as RaporSonuc);
const params = satir.params as RaporParams;
return (
<main className="mx-auto w-full max-w-6xl px-4 py-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="font-heading text-2xl font-bold sm:text-3xl">
Listem
</h1>
<p className="mt-1 text-sm text-slate-600">
{params.sira.toLocaleString("tr-TR")}. sıra ·{" "}
{PUAN_TURLERI[params.tur]} · 24 tercih, yerleşme riskine göre
renkli. Sorularını yandaki danışmana sorabilirsin (1 mesaj = 1
kredi).
</p>
</div>
{user.hasPaket ? (
<div className="flex gap-2">
<Button asChild variant="outline" size="sm" className="cursor-pointer">
<Link
href={`/sonuc?sira=${params.sira}&tur=${params.tur}&sihirbaz=1`}
>
<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-6 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_400px]">
<RaporListesi
rapor={rapor}
adaySira={params.sira}
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
kilitOverlay={
<>
<Lock className="size-8 text-slate-500" aria-hidden />
<p className="max-w-sm text-center font-heading text-lg font-bold text-slate-900">
24 tercihin {24 - ACIK_SATIR}&apos;i kilidin ardında
</p>
<Button
asChild
size="lg"
className="h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/paket">299 TL Tüm listeyi </Link>
</Button>
</>
}
/>
<div className="lg:sticky lg:top-28">
<SohbetClient baslangicKredi={user.creditBalance} />
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,260 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowUp, Coins, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
ChatContainerContent,
ChatContainerRoot,
ChatContainerScrollAnchor,
} from "@/components/ui/chat-container";
import { Message, MessageContent } from "@/components/ui/message";
import {
PromptInput,
PromptInputAction,
PromptInputActions,
PromptInputTextarea,
} from "@/components/ui/prompt-input";
import { PromptSuggestion } from "@/components/ui/prompt-suggestion";
import { ScrollButton } from "@/components/ui/scroll-button";
import { DotsLoader } from "@/components/ui/loader";
interface Mesaj {
id: string;
role: "user" | "assistant";
content: string;
}
const ORNEK_SORULAR = [
"Listemdeki kırmızı (riskli) tercihler ne kadar riskli?",
"İlk 5 tercihimin taban sıralaması trendi nasıl?",
"Güvenli dilimden bir tercihi daha üste alsam mantıklı mı?",
"X bölümü ile Y bölümü arasında kalırsam neye bakmalıyım?",
];
/**
* Listem sayfasındaki danışman sohbeti. /api/soru ile konuşur:
* GET geçmiş + kredi, POST 1 kredi düşerek cevabı stream eder.
*/
export function SohbetClient({
baslangicKredi,
className,
}: {
baslangicKredi: number;
/** Boyutlandırma override'ı (ör. alt popup'ta h-full) */
className?: string;
}) {
const [mesajlar, setMesajlar] = useState<Mesaj[]>([]);
const [girdi, setGirdi] = useState("");
const [bekliyor, setBekliyor] = useState(false);
const [yuklendi, setYuklendi] = useState(false);
const [kredi, setKredi] = useState(baslangicKredi);
useEffect(() => {
fetch("/api/soru")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.mesajlar) setMesajlar(data.mesajlar);
if (typeof data?.kredi === "number") setKredi(data.kredi);
})
.catch(() => {})
.finally(() => setYuklendi(true));
}, []);
async function gonder(metin: string) {
const mesaj = metin.trim();
if (!mesaj || bekliyor) return;
if (kredi < 1) {
toast.error("Kredin bitti — devam etmek için kredi yükle.");
return;
}
setGirdi("");
setBekliyor(true);
const clientMessageId = crypto.randomUUID();
const asistanId = crypto.randomUUID();
setMesajlar((m) => [
...m,
{ id: clientMessageId, role: "user", content: mesaj },
{ id: asistanId, role: "assistant", content: "" },
]);
try {
const res = await fetch("/api/soru", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: mesaj, clientMessageId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setMesajlar((m) =>
m.filter((x) => x.id !== asistanId && x.id !== clientMessageId),
);
if (res.status === 402) {
toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin.");
setKredi(0);
} else {
toast.error(data.error ?? "Bir sorun oluştu, tekrar dener misin?");
}
return;
}
setKredi((k) => k - 1);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let birikmis = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
birikmis += decoder.decode(value, { stream: true });
const anlik = birikmis;
setMesajlar((m) =>
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
);
}
} catch {
toast.error("Bağlantı koptu. Cevabı sayfayı yenileyerek görebilirsin.");
} finally {
setBekliyor(false);
}
}
const krediYok = kredi < 1;
return (
<section
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]",
)}
>
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<h2 className="font-heading text-base font-bold">Danışmanla konuş</h2>
<Link
href="/paket"
className="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700 transition-colors hover:bg-amber-100"
title="Kredilerin — yüklemek için tıkla"
>
<Coins className="size-3.5" aria-hidden />
{kredi} kredi
</Link>
</div>
<div className="relative flex-1 overflow-hidden">
<ChatContainerRoot className="h-full">
<ChatContainerContent className="gap-4 p-4">
{mesajlar.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-10 text-center">
<Sparkles className="size-7 text-primary" aria-hidden />
<div>
<p className="font-heading font-bold">
Listen hakkında her şeyi sorabilirsin
</p>
<p className="mx-auto mt-1 max-w-md text-sm text-slate-600">
Cevaplar senin 24&apos;lük listen, seçimlerin ve gerçek
YÖK Atlas verisi bağlamında üretilir. Her mesaj 1 kredi.
</p>
</div>
{yuklendi ? (
<div className="flex max-w-lg flex-wrap justify-center gap-2">
{ORNEK_SORULAR.map((s) => (
<PromptSuggestion
key={s}
size="sm"
className="cursor-pointer text-xs"
disabled={krediYok}
onClick={() => gonder(s)}
>
{s}
</PromptSuggestion>
))}
</div>
) : (
<DotsLoader />
)}
</div>
) : (
mesajlar.map((m) =>
m.role === "user" ? (
<Message key={m.id} className="justify-end">
<MessageContent className="max-w-[85%] bg-primary text-white">
{m.content}
</MessageContent>
</Message>
) : (
<Message key={m.id} className="justify-start">
{m.content ? (
<MessageContent
markdown
className="max-w-[85%] bg-slate-100 text-slate-800 prose-sm"
>
{m.content}
</MessageContent>
) : (
<div className="rounded-lg bg-slate-100 px-4 py-3">
<DotsLoader size="sm" />
</div>
)}
</Message>
),
)
)}
<ChatContainerScrollAnchor />
</ChatContainerContent>
<div className="absolute bottom-4 right-4">
<ScrollButton className="cursor-pointer bg-white shadow-md" />
</div>
</ChatContainerRoot>
</div>
<div className="border-t border-slate-100 p-3">
{krediYok ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
<p className="text-sm font-medium text-amber-800">
Kredin bitti sohbete devam etmek için kredi yükle.
</p>
<Button
asChild
size="sm"
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
<Link href="/paket">Kredi yükle</Link>
</Button>
</div>
) : (
<PromptInput
value={girdi}
onValueChange={setGirdi}
onSubmit={() => gonder(girdi)}
isLoading={bekliyor}
disabled={bekliyor}
maxHeight={160}
className="rounded-2xl border-slate-200"
>
<PromptInputTextarea
placeholder="Sorunu yaz… (1 kredi)"
maxLength={2000}
/>
<PromptInputActions className="justify-end pt-1">
<PromptInputAction tooltip="Gönder (Enter)">
<Button
size="icon"
onClick={() => gonder(girdi)}
disabled={bekliyor || !girdi.trim()}
className="size-9 cursor-pointer rounded-full bg-orange-500 text-white hover:bg-orange-600"
aria-label="Gönder"
>
{bekliyor ? <DotsLoader size="sm" /> : <ArrowUp className="size-4" aria-hidden />}
</Button>
</PromptInputAction>
</PromptInputActions>
</PromptInput>
)}
</div>
</section>
);
}

View File

@@ -38,6 +38,22 @@ export default async function OdemeSonucPage({
const basarili = durum === "paid";
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
// sonuç sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
const rapor = basarili
? await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, session.user.id),
columns: { params: true },
})
: null;
const raporParams = rapor?.params as
| { sira?: number; tur?: string }
| undefined;
const listeHref =
raporParams?.sira && raporParams?.tur
? `/sonuc?sira=${raporParams.sira}&tur=${raporParams.tur}&acildi=1`
: null;
return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16 text-center">
@@ -64,13 +80,17 @@ export default async function OdemeSonucPage({
asChild
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/">
<Link href={listeHref ?? "/"}>
<Sparkles className="size-4" aria-hidden />
24&apos;lük listeni oluştur
{listeHref
? "Listeni aç — kilidi kaldırdık"
: "24'lük listeni oluştur"}
</Link>
</Button>
<p className="text-center text-xs text-slate-500">
Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur.
{listeHref
? "24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık."
: "Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur."}
</p>
</div>
</div>

View File

@@ -13,6 +13,7 @@ import {
} from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { sihirbazDogrula, type SihirbazSecimleri } from "@/lib/sihirbaz";
import { raporMaskele } from "@/lib/rapor-maske";
const MAX_REVIZYON = 2;
@@ -24,6 +25,7 @@ export type ListeSonuc =
params: RaporParams;
revisionCount: number;
kredi: number;
hasPaket: boolean;
}
| { ok: false; error: string; code: "AUTH" | "PAKET" | "KREDI" | "HATA" };
@@ -71,9 +73,8 @@ export async function listeOlustur(input: {
if (!user) {
return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
}
if (!user.hasPaket) {
return { ok: false, code: "PAKET", error: "Paket gerekli." };
}
// Paket şartı yok: üretim kredi bazlı (yeni kullanıcının 5 deneme kredisi
// burada yanmaya başlar). Paketsize dönen rapor maskelenir.
if (!siraTurGecerli(input.sira, input.tur)) {
return { ok: false, code: "HATA", error: "Geçerli bir sıralama gerekli." };
@@ -112,12 +113,14 @@ export async function listeOlustur(input: {
where: eq(schema.reports.userId, user.id),
});
if (mevcut?.result) {
const rapor = mevcut.result as RaporSonuc;
return {
ok: true,
rapor: mevcut.result as RaporSonuc,
rapor: user.hasPaket ? rapor : raporMaskele(rapor),
params: mevcut.params as RaporParams,
revisionCount: mevcut.revisionCount,
kredi: user.creditBalance,
hasPaket: user.hasPaket,
};
}
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
@@ -156,10 +159,11 @@ export async function listeOlustur(input: {
return {
ok: true,
rapor: sonuc,
rapor: user.hasPaket ? sonuc : raporMaskele(sonuc),
params,
revisionCount: 0,
kredi: user.creditBalance - RAPOR_KREDI,
hasPaket: user.hasPaket,
};
}
@@ -221,7 +225,11 @@ export async function listeRevize(input: {
let sonuc: RaporSonuc;
try {
sonuc = await raporUret(params, feedback.slice(0, 1000), listeOzetiCikar(onceki));
sonuc = await raporUret(
params,
feedback.slice(0, 1000),
listeOzetiCikar(onceki, params.sira),
);
} catch (err) {
await grantCredits({
userId: user.id,
@@ -248,5 +256,6 @@ export async function listeRevize(input: {
params,
revisionCount: rapor.revisionCount + 1,
kredi: user.creditBalance - RAPOR_KREDI,
hasPaket: true,
};
}

View File

@@ -0,0 +1,68 @@
"use client";
import Link from "next/link";
import { ArrowRight, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { TypingAnimation } from "@/components/ui/typing-animation";
/** Banner CTA'sının sihirbaz modalını açmak için yayınladığı olay. */
export const SIHIRBAZ_AC_EVENT = "kolaytercih:sihirbaz-ac";
const CUMLELER = [
"Seçmekte zorlanıyor musunuz?",
"Hangi bölüm size uygun, emin değil misiniz?",
"Yapay zekâ 24 tercihlik listenizi kursun.",
"Her satırda gerekçe ve yerleşme riski analizi.",
];
/**
* Puan türü badge'lerinin hemen altında yaşayan satın alım funnel banner'ı.
* Sürekli değişen (yazılıp silinen) cümlelerle adayı sihirbaza; rapor zaten
* varsa kilitli listenin durduğu /listem paywall'ına iter.
* Tasarım: flat indigo — gradient/gölge yok, CTA funnel turuncusu.
*/
export function FunnelBanner({ hedefListem }: { hedefListem: boolean }) {
const ctaSinif =
"h-10 shrink-0 cursor-pointer bg-orange-500 px-5 font-semibold text-white transition-colors duration-200 hover:bg-orange-600 focus-visible:ring-white/70";
return (
<section
aria-label="Tercih listeni kur"
className="mt-6 flex flex-col gap-4 rounded-2xl bg-indigo-600 p-5 sm:flex-row sm:items-center sm:gap-6"
>
<div className="flex min-w-0 flex-1 items-center gap-4">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-indigo-500"
aria-hidden
>
<Sparkles className="size-5 text-white" />
</div>
<TypingAnimation
as="p"
words={CUMLELER}
loop
typeSpeed={45}
deleteSpeed={20}
pauseDelay={2400}
className="min-h-14 min-w-0 flex-1 font-heading text-lg font-bold leading-snug text-white sm:min-h-8 sm:text-2xl"
/>
</div>
{hedefListem ? (
<Button asChild className={ctaSinif}>
<Link href="/listem">
Listeni
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
) : (
<Button
onClick={() => window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT))}
className={ctaSinif}
>
Listemi oluştur
<ArrowRight className="size-4" aria-hidden />
</Button>
)}
</section>
);
}

View File

@@ -0,0 +1,369 @@
"use client";
import { useMemo, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { FileText, Loader2, Lock, RefreshCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
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") {
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">
Liste üretimi 3 kredi. Tercih Dönemi Paketi&apos;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">299 TL Paketi aktive et</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
? `ilk ${ACIK_SATIR} tercih açık`
: `${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={
<>
<Lock className="size-8 text-slate-500" aria-hidden />
<p className="max-w-sm text-center font-heading text-lg font-bold text-slate-900">
24 tercihin {24 - ACIK_SATIR}&apos;i kilidin ardında
</p>
<p className="max-w-sm text-center text-sm text-slate-600">
Her satır için gerekçe, risk notu ve 4 yıllık trend + PDF çıktı
+ 2 revizyon hakkı + 60 kredi seni bekliyor.
</p>
<Button
asChild
size="lg"
className="h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/paket">299 TL Tüm listeyi </Link>
</Button>
{kredi > 0 ? (
<p className="text-xs text-slate-500">
Kalan {kredi} kredin AI danışmana soru sormak için cebinde
duruyor.
</p>
) : null}
</>
}
/>
</div>
{!kilitli ? (
<div className="mt-6">
<RevizyonKutusu
kalanHak={MAX_REVIZYON - rapor.revisionCount}
onRevize={onRevize}
/>
</div>
) : null}
{/* AI danışman: sağ altta yüzen buton, alttan popup olarak açılır */}
<SohbetPopup kredi={kredi} />
</section>
);
}
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 className="mt-16 flex flex-col items-center justify-center gap-4 rounded-2xl border border-slate-200 bg-white py-16 text-center">
<Loader2 className="size-10 animate-spin text-primary" aria-hidden />
<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>
</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"
>
{pending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<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

@@ -4,18 +4,21 @@ import { ArrowLeft } from "lucide-react";
import { eq } from "drizzle-orm";
import {
PUAN_TURLERI,
searchByRank,
rankWindowFacets,
searchByRank,
type PuanTuruKey,
} from "@/lib/db";
import { getSession, getCurrentUser } 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 { SihirbazBolumu } from "./sihirbaz-cagri-karti";
import type { MevcutRapor, OnizlemeSatir } from "./sihirbaz-modal";
import { FunnelBanner } from "./funnel-banner";
import type { MevcutRapor } from "./liste-paneli";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ProgramTablosu } from "@/components/program-tablosu";
export const metadata: Metadata = {
title: "Tercih Listeni Kur — KolayTercih",
@@ -36,6 +39,7 @@ export default async function SonucPage({
sira?: string;
tur?: string;
sihirbaz?: string;
acildi?: string;
}>;
}) {
const params = await searchParams;
@@ -74,24 +78,20 @@ export default async function SonucPage({
: null;
const facetler = rankWindowFacets(sira, turKey);
// AI'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: raporSatiri.result as RaporSonuc,
rapor: hasPaket
? (raporSatiri.result as RaporSonuc)
: raporMaskele(raporSatiri.result as RaporSonuc),
params: raporSatiri.params as RaporParams,
revisionCount: raporSatiri.revisionCount,
}
: null;
// Paketsiz kullanıcıya blur önizlemede gösterilecek gerçek dengeli satırlar
const onizlemeSatirlari: OnizlemeSatir[] = searchByRank(sira, turKey, {
limitPerBucket: 5,
})
.dengeli.slice(0, 5)
.map((p) => ({
id: p.id,
isim: p.isim,
universite: p.universite,
il: p.il,
}));
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
@@ -141,18 +141,27 @@ export default async function SonucPage({
))}
</nav>
{/* Sihirbaz akışı (CTA + modal) — seçimler ana sayfadaki modalda yapılır,
burada yalnızca giriş dönüşünde (?sihirbaz=1) otomatik açılır */}
{/* Satın alım funnel'ı: değişen cümlelerle adayı sihirbaza (rapor
varsa /listem paywall'ına) iten banner — paketli kullanıcıya
satacak bir şey kalmadığından gösterilmez */}
{!hasPaket ? <FunnelBanner hedefListem={Boolean(mevcutRapor)} /> : null}
{/* AI'dan bağımsız ham tablo + manuel liste haritası — sayfanın ana
gövdesi; + ile manuel 24'lük liste kurulur */}
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} />
{/* Sihirbaz akışı (CTA + modal + AI listesi) — blurlu AI paneli
sayfanın en altında yaşar */}
<SihirbazBolumu
sira={sira}
tur={turKey}
facetler={facetler}
girisliMi={Boolean(session)}
hasPaket={Boolean(user?.hasPaket)}
hasPaket={hasPaket}
kredi={user?.creditBalance ?? 0}
mevcutRapor={mevcutRapor}
onizlemeSatirlari={onizlemeSatirlari}
otomatikAc={params.sihirbaz === "1"}
acilis={params.acildi === "1"}
/>
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">

View File

@@ -1,7 +1,15 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import {
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Sparkles, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import type { SihirbazFacetleri } from "@/lib/db";
import {
@@ -9,11 +17,14 @@ import {
sihirbazDogrula,
type SihirbazSecimleri,
} from "@/lib/sihirbaz";
import { SihirbazModal } from "./sihirbaz-modal";
import { SIHIRBAZ_AC_EVENT } from "./funnel-banner";
import {
SihirbazModal,
ListePaneli,
type MevcutRapor,
type OnizlemeSatir,
} from "./sihirbaz-modal";
type PanelDurum,
} from "./liste-paneli";
import { listeOlustur, listeRevize } from "./actions";
const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali";
@@ -32,11 +43,12 @@ type BekleyenSecim = {
};
/**
* Sonuç sayfasında sihirbaz akışını yöneten client wrapper:
* - Sayfa ortasındaki büyük CTA (satır içi)
* - Sabit alt çağrı kartı
* - Modal
* hepsi tek `acik` state'ini paylaşır.
* 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)
* - Adımları toplayan modal
* - Üretim/önizleme/liste paneli (sayfa gövdesinde)
* Akış: adımlar → (girişsizse /giris) → kredi ile üretim → paketsizse
* progressive-blur önizleme, paketliyse tam liste.
*/
export function SihirbazBolumu({
sira,
@@ -44,10 +56,10 @@ export function SihirbazBolumu({
facetler,
girisliMi,
hasPaket,
kredi,
kredi: baslangicKredi,
mevcutRapor,
onizlemeSatirlari,
otomatikAc,
acilis,
}: {
sira: number;
tur: string;
@@ -56,18 +68,34 @@ export function SihirbazBolumu({
hasPaket: boolean;
kredi: number;
mevcutRapor: MevcutRapor | null;
onizlemeSatirlari: OnizlemeSatir[];
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
otomatikAc?: boolean;
/** ?acildi=1 (ödeme dönüşü): blur çözülme animasyonuyla aç */
acilis?: boolean;
}) {
const router = useRouter();
const monteEdildi = useMonteEdildi();
const [elleAcik, setElleAcik] = useState(false);
const [elleKapandi, setElleKapandi] = useState(false);
const [kartGizle, setKartGizle] = useState(false);
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
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);
const otomatikBasladi = useRef(false);
// Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku
// (dış store'dan başlangıç senkronizasyonu — bir kez).
useEffect(() => {
try {
const kaliciKapali = Boolean(sessionStorage.getItem(KAPATMA_KEY));
@@ -93,11 +121,100 @@ export function SihirbazBolumu({
} catch {}
}, [sira, tur]);
// Giriş/ödeme dönüşünde otomatik üretim: eşleşen bekleyen seçim + paket
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && hasPaket);
// Açık durumu türetilmiş: elle veya otomatik tetik, kullanıcı kapatmadıkça
const acik = !elleKapandi && (elleAcik || Boolean(otomatikAc) || otomatikUret);
const baslangicSecimler = bekleyen?.eslesir ? bekleyen.secimler : null;
// Üstteki funnel banner'ının CTA'sı sihirbaz modalını bu olayla açar
useEffect(() => {
const ac = () => {
setElleAcik(true);
setElleKapandi(false);
};
window.addEventListener(SIHIRBAZ_AC_EVENT, ac);
return () => window.removeEventListener(SIHIRBAZ_AC_EVENT, ac);
}, []);
async function uret(secimler: SihirbazSecimleri) {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
setElleAcik(false);
setDurum("uretiliyor");
try {
const sonuc = await listeOlustur({
sira,
tur,
secimler,
requestId: requestIdRef.current,
});
if (sonuc.ok) {
requestIdRef.current = null;
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
setPaketli(sonuc.hasPaket);
try {
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
} catch {}
// AI çı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");
return;
}
// Hata: aynı requestId'yi koru ki tekrar denemede çifte harcama olmasın
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
setDurum("kredi-yetersiz");
} else if (sonuc.code === "AUTH") {
secimleriKaydetVeGirise(secimler);
} else {
toast.error(sonuc.error);
setDurum("bos");
setElleAcik(true);
}
} catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
setDurum("bos");
setElleAcik(true);
}
}
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur, secimler }),
);
} catch {}
const geri = `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`;
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
}
function ilerle(secimler: SihirbazSecimleri) {
setSonSecimler(secimler);
if (!girisliMi) {
secimleriKaydetVeGirise(secimler);
return;
}
void uret(secimler);
}
// 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);
useEffect(() => {
if (otomatikUret && bekleyen && !otomatikBasladi.current) {
otomatikBasladi.current = true;
setSonSecimler(bekleyen.secimler);
void uret(bekleyen.secimler);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [otomatikUret]);
// ?sihirbaz=1: otomatik üretim devreye girmediyse (seçim kaybolduysa/
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin.
const acik =
!elleKapandi &&
(elleAcik ||
Boolean(otomatikAc && !otomatikUret && !rapor && durum === "bos"));
function acikDegisti(v: boolean) {
if (v) {
@@ -109,43 +226,80 @@ export function SihirbazBolumu({
}
}
// Üretim başladıysa storage'ı temizle (modal artık state'te tutuyor)
useEffect(() => {
if (acik && otomatikUret) {
try {
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
} catch {}
}
}, [acik, otomatikUret]);
const raporVar = Boolean(mevcutRapor);
const panelde = Boolean(rapor) || durum !== "bos";
return (
<>
{/* Sayfa ortasındaki büyük CTA */}
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
{raporVar
? "24 tercihlik listen hazır"
: "Bu listeyi 24 tercihlik plana çevirelim mi?"}
</h2>
<p className="mx-auto mt-3 max-w-xl text-white/90">
{raporVar
? "Listeni görüntüle, revize et veya listen hakkında soru sor."
: "Birkaç adımda ilgi alanların ve şehir tercihine göre gerekçeli, dengeli bir tercih listesi kuralım — her satır için yerleşme riski analiziyle."}
</p>
<Button
size="lg"
onClick={() => acikDegisti(true)}
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
{raporVar ? "Listemi görüntüle" : "Listemi oluştur"}
</Button>
</section>
{/* AI çı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. */}
{durum !== "bos" ? (
<ListePaneli
durum={durum}
rapor={null}
hasPaket={paketli}
kredi={kredi}
sira={sira}
acilis={acilis}
onRevize={async (feedback) => {
const sonuc = await listeRevize({
feedback,
requestId: crypto.randomUUID(),
});
if (sonuc.ok) {
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>
) : (
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
Bu listeyi 24 tercihlik plana çevirelim mi?
</h2>
<p className="mx-auto mt-3 max-w-xl text-white/90">
Birkaç adımda ilgi alanların ve şehir tercihine göre gerekçeli,
dengeli bir tercih listesi kuralım her satır için yerleşme riski
analiziyle.
</p>
<Button
size="lg"
onClick={() => acikDegisti(true)}
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
Listemi oluştur
</Button>
</section>
)}
{/* Sabit alt çağrı kartırapor yoksa ve kart kapatılmadıysa */}
{monteEdildi && !raporVar && !kartGizle && !acik ? (
{/* Sabit alt çağrı kartıliste/panel yokken ve kart kapatılmadıysa */}
{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">
@@ -184,16 +338,14 @@ export function SihirbazBolumu({
<SihirbazModal
acik={acik}
onAcikDegisti={acikDegisti}
sira={sira}
tur={tur}
facetler={facetler}
girisliMi={girisliMi}
hasPaket={hasPaket}
kredi={kredi}
mevcutRapor={mevcutRapor}
onizlemeSatirlari={onizlemeSatirlari}
otomatikUret={otomatikUret}
baslangicSecimler={baslangicSecimler}
baslangicSecimler={
sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
}
sonButonEtiketi={
girisliMi ? "Listemi oluştur (3 kredi)" : "Listemi gör"
}
onTamamla={ilerle}
/>
) : null}
</>

View File

@@ -1,365 +1,35 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
FileText,
Loader2,
Lock,
RefreshCcw,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { RaporListesi } from "@/components/rapor-listesi";
import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar";
import type { SihirbazFacetleri } from "@/lib/db";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import {
SIHIRBAZ_STORAGE_KEY,
type SihirbazSecimleri,
} from "@/lib/sihirbaz";
import { listeOlustur, listeRevize } from "./actions";
const MAX_REVIZYON = 2;
export type OnizlemeSatir = {
id: string;
isim: string;
universite: string;
il: string | null;
};
export type MevcutRapor = {
rapor: RaporSonuc;
params: RaporParams;
revisionCount: number;
};
type Faz = "adimlar" | "onizleme" | "uretiliyor" | "liste";
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…",
];
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
* sihirbaz-cagri-karti.tsx'teki SihirbazBolumu'ndadır.
*/
export function SihirbazModal({
acik,
onAcikDegisti,
sira,
tur,
facetler,
girisliMi,
hasPaket,
kredi: baslangicKredi,
mevcutRapor,
onizlemeSatirlari,
otomatikUret,
baslangicSecimler,
sonButonEtiketi,
onTamamla,
}: {
acik: boolean;
onAcikDegisti: (acik: boolean) => void;
sira: number;
tur: string;
facetler: SihirbazFacetleri;
girisliMi: boolean;
hasPaket: boolean;
kredi: number;
mevcutRapor: MevcutRapor | null;
onizlemeSatirlari: OnizlemeSatir[];
/** Giriş dönüşü: storage'daki seçimlerle otomatik üretime başla */
otomatikUret?: boolean;
baslangicSecimler?: SihirbazSecimleri | null;
sonButonEtiketi: string;
onTamamla: (secimler: SihirbazSecimleri) => void;
}) {
const router = useRouter();
const [faz, setFaz] = useState<Faz>(mevcutRapor ? "liste" : "adimlar");
const [kredi, setKredi] = useState(baslangicKredi);
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
const [uretimMesajIdx, setUretimMesajIdx] = useState(0);
// Adımlarda yapılan son seçimler (önizlemeden geri dönüşte prefill için)
const [sonSecimler, setSonSecimler] = useState<SihirbazSecimleri | null>(
baslangicSecimler ?? null,
);
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
const requestIdRef = useRef<string | null>(null);
const otomatikBasladi = useRef(false);
async function uret(secimler: SihirbazSecimleri) {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
setFaz("uretiliyor");
setUretimMesajIdx(0);
const interval = setInterval(() => {
setUretimMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
}, 7000);
try {
const sonuc = await listeOlustur({
sira,
tur,
secimler,
requestId: requestIdRef.current,
});
if (sonuc.ok) {
requestIdRef.current = null;
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
setFaz("liste");
return;
}
// Hata: aynı requestId'yi koru ki tekrar denemede çifte harcama olmasın
if (sonuc.code === "KREDI") {
toast.error(sonuc.error);
setFaz("adimlar");
} else if (sonuc.code === "PAKET") {
setFaz("onizleme");
} else if (sonuc.code === "AUTH") {
secimleriKaydetVeGirise(secimler);
} else {
toast.error(sonuc.error);
setFaz("adimlar");
}
} catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
setFaz("adimlar");
} finally {
clearInterval(interval);
}
}
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur, secimler }),
);
} catch {}
const geri = `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`;
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
}
// Giriş dönüşü: storage'daki seçimlerle otomatik üretime başla (bir kez)
useEffect(() => {
if (
otomatikUret &&
baslangicSecimler &&
hasPaket &&
girisliMi &&
!otomatikBasladi.current &&
!mevcutRapor
) {
otomatikBasladi.current = true;
void uret(baslangicSecimler);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Paketsiz kullanıcı önizlemeye geçerken seçimleri sakla ki ödeme dönüşünde
// (aynı sıralamayla) sihirbaz kaldığı yerden devam edebilsin.
function secimleriSakla(secimler: SihirbazSecimleri) {
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur, secimler }),
);
} catch {}
}
function ilerle(secimler: SihirbazSecimleri) {
setSonSecimler(secimler);
if (!girisliMi) {
secimleriKaydetVeGirise(secimler);
return;
}
if (!hasPaket) {
secimleriSakla(secimler);
setFaz("onizleme");
return;
}
void uret(secimler);
}
// ---- Faz gövdeleri ----
const adimlarGovde = (
<SihirbazAdimlar
facetler={facetler}
baslangicSecimler={sonSecimler}
sonButonEtiketi={
hasPaket ? "24'lük listemi oluştur (3 kredi)" : "Listemi gör"
}
onTamamla={ilerle}
/>
);
const onizlemeGovde = (
<div>
<h3 className="font-heading text-xl font-bold">
24 tercihlik listen hazır olmak üzere
</h3>
<p className="mt-2 text-sm text-slate-600">
Aşağıda ilk birkaç satırın ön izlemesi var. Tam listeyi, gerekçeleri, risk
analizini ve PDF çıktıyı görmek için paketi aktive et.
</p>
<div className="mt-6 overflow-hidden rounded-2xl border border-slate-200">
{onizlemeSatirlari.length > 0 ? (
<ul className="divide-y divide-slate-100">
{onizlemeSatirlari.map((p, i) => (
<li key={p.id} className="flex items-center gap-4 px-5 py-3">
<span className="w-6 text-right font-heading font-bold text-slate-400">
{i + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{p.isim}</p>
<p className="truncate text-sm text-slate-500">
{p.universite} · {p.il ?? "—"}
</p>
</div>
<Badge variant="secondary" className="bg-blue-100 text-blue-700">
Dengeli
</Badge>
</li>
))}
</ul>
) : null}
<div className="relative">
<ul className="divide-y divide-slate-100 blur-sm select-none" aria-hidden>
{Array.from({ length: 7 }).map((_, i) => (
<li key={i} className="flex items-center gap-4 px-5 py-3">
<span className="w-6 text-right font-heading font-bold text-slate-300">
{onizlemeSatirlari.length + i + 1}
</span>
<div className="flex-1 space-y-1.5">
<div className="h-3.5 w-2/3 rounded bg-slate-200" />
<div className="h-3 w-1/2 rounded bg-slate-100" />
</div>
<div className="h-5 w-16 rounded-full bg-slate-100" />
</li>
))}
</ul>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-gradient-to-b from-white/30 via-white/80 to-white">
<Lock className="size-8 text-slate-400" aria-hidden />
<p className="max-w-sm text-center text-sm font-medium text-slate-700">
+19 tercih, her satır için gerekçe ve risk analizi, PDF çıktı ve AI
soru hakkı seni bekliyor.
</p>
<Button
asChild
size="lg"
className="h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/paket">299 TL Paketi aktive et</Link>
</Button>
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
onClick={() => setFaz("adimlar")}
className="mt-4 cursor-pointer"
>
<ArrowLeft className="size-4" aria-hidden />
Seçimlere dön
</Button>
</div>
);
const uretiliyorGovde = (
<div className="flex flex-col items-center justify-center gap-4 py-16 text-center">
<Loader2 className="size-10 animate-spin text-primary" aria-hidden />
<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[uretimMesajIdx]}
</p>
<p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p>
</div>
);
const listeGovde = rapor ? (
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="font-heading text-xl font-bold">24 Tercihlik Listen</h3>
<p className="mt-1 text-sm text-slate-500">
{sira.toLocaleString("tr-TR")}. sıra · {kredi} kredin kaldı
</p>
</div>
<Button asChild variant="outline" className="cursor-pointer">
<Link href="/rapor/yazdir" target="_blank">
<FileText className="size-4" aria-hidden />
PDF indir
</Link>
</Button>
</div>
<div className="mt-5">
<RaporListesi rapor={rapor.rapor} adaySira={rapor.params.sira} />
</div>
<div className="mt-6">
<RevizyonKutusu
kalanHak={MAX_REVIZYON - rapor.revisionCount}
onRevize={async (feedback) => {
const sonuc = await listeRevize({
feedback,
requestId: crypto.randomUUID(),
});
if (sonuc.ok) {
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;
}}
/>
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4">
<div>
<p className="font-heading text-sm font-bold">
Listen hakkında soruların mı var?
</p>
<p className="text-xs text-slate-500">
AI danışman listeni ve seçimlerini bilir; her mesaj 1 kredi.
</p>
</div>
<Button
asChild
size="sm"
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/listem">Danışmana sor</Link>
</Button>
</div>
</div>
) : null;
return (
<Dialog open={acik} onOpenChange={onAcikDegisti}>
<DialogContent className="max-h-[92dvh] gap-0 overflow-y-auto p-6 sm:max-w-3xl">
@@ -367,80 +37,13 @@ export function SihirbazModal({
<DialogDescription className="sr-only">
Sıralamana uygun 24 tercihlik listeni oluştur.
</DialogDescription>
{faz === "adimlar"
? adimlarGovde
: faz === "onizleme"
? onizlemeGovde
: faz === "uretiliyor"
? uretiliyorGovde
: listeGovde}
<SihirbazAdimlar
facetler={facetler}
baslangicSecimler={baslangicSecimler}
sonButonEtiketi={sonButonEtiketi}
onTamamla={onTamamla}
/>
</DialogContent>
</Dialog>
);
}
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"
>
{pending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<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

@@ -0,0 +1,73 @@
"use client";
// AI 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 { MessageCircleMore, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { SohbetClient } from "@/app/listem/sohbet-client";
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 (
<>
<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>
</>
);
}

View File

@@ -1,222 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Coins, Loader2, SendHorizonal, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface Mesaj {
id: string;
role: "user" | "assistant";
content: string;
}
const ORNEK_SORULAR = [
"Listemdeki ilk 5 tercih çok mu riskli?",
"Bu sıralamayla hangi bölüme kesin girerim?",
"İlk tercihimle ikinciyi neden bu sırada koydun?",
];
/**
* Liste oluşturulduktan sonra, o listeye bağlı bağlamsal soru-cevap kutusu.
* Serbest sohbet değil: her soru 1 kredi, cevap /api/soru'dan stream edilir.
*/
export function SoruKutusu({
kredi,
onKrediDegisti,
}: {
// Kredi tümüyle kontrollü: kaynak modaldaki state (tek doğruluk kaynağı)
kredi: number;
onKrediDegisti: (kredi: number) => void;
}) {
const [mesajlar, setMesajlar] = useState<Mesaj[]>([]);
const [girdi, setGirdi] = useState("");
const [bekliyor, setBekliyor] = useState(false);
const [yuklendi, setYuklendi] = useState(false);
const altRef = useRef<HTMLDivElement>(null);
// Geçmiş soru-cevapları ve güncel krediyi yükle
useEffect(() => {
fetch("/api/soru")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.mesajlar) setMesajlar(data.mesajlar);
if (typeof data?.kredi === "number") onKrediDegisti(data.kredi);
})
.catch(() => {})
.finally(() => setYuklendi(true));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (mesajlar.length > 0) {
altRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [mesajlar]);
function krediGuncelle(yeni: number) {
onKrediDegisti(yeni);
}
async function gonder(metin: string) {
const mesaj = metin.trim();
if (!mesaj || bekliyor) return;
if (kredi < 1) {
toast.error("Kredin bitti — devam etmek için kredi yükle.");
return;
}
setGirdi("");
setBekliyor(true);
const clientMessageId = crypto.randomUUID();
const asistanId = crypto.randomUUID();
setMesajlar((m) => [
...m,
{ id: clientMessageId, role: "user", content: mesaj },
{ id: asistanId, role: "assistant", content: "" },
]);
try {
const res = await fetch("/api/soru", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: mesaj, clientMessageId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setMesajlar((m) =>
m.filter((x) => x.id !== asistanId && x.id !== clientMessageId),
);
if (res.status === 402) {
toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin.");
krediGuncelle(0);
} else {
toast.error(data.error ?? "Bir sorun oluştu, tekrar dener misin?");
}
return;
}
krediGuncelle(kredi - 1);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let birikmis = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
birikmis += decoder.decode(value, { stream: true });
const anlik = birikmis;
setMesajlar((m) =>
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
);
}
} catch {
toast.error("Bağlantı koptu. Cevabı sayfayı yenileyerek görebilirsin.");
} finally {
setBekliyor(false);
}
}
return (
<div className="rounded-2xl border border-slate-200 bg-white">
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<h3 className="font-heading text-base font-bold">Listen hakkında sor</h3>
<span className="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700">
<Coins className="size-3.5" aria-hidden />
{kredi} kredi
</span>
</div>
<div className="max-h-72 space-y-3 overflow-y-auto p-4">
{mesajlar.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<Sparkles className="size-6 text-primary" aria-hidden />
<p className="max-w-sm text-sm text-slate-600">
Bu listeye özel soru sorabilirsin. Her soru 1 kredi kullanır.
</p>
{yuklendi ? (
<div className="flex flex-wrap justify-center gap-2">
{ORNEK_SORULAR.map((s) => (
<button
key={s}
onClick={() => gonder(s)}
disabled={kredi < 1}
className="cursor-pointer rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-700 transition-colors duration-200 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
>
{s}
</button>
))}
</div>
) : null}
</div>
) : (
mesajlar.map((m) => (
<div
key={m.id}
className={
m.role === "user" ? "flex justify-end" : "flex justify-start"
}
>
<div
className={
m.role === "user"
? "max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-white"
: "max-w-[85%] whitespace-pre-line rounded-2xl rounded-bl-sm bg-slate-100 px-4 py-2.5 text-sm text-slate-800"
}
>
{m.content || (
<Loader2
className="size-4 animate-spin text-slate-400"
aria-hidden
/>
)}
</div>
</div>
))
)}
<div ref={altRef} />
</div>
<form
className="flex gap-2 border-t border-slate-100 p-3"
onSubmit={(e) => {
e.preventDefault();
gonder(girdi);
}}
>
<Input
value={girdi}
onChange={(e) => setGirdi(e.target.value)}
placeholder={
kredi < 1 ? "Kredin bitti" : "Sorunu yaz… (1 kredi)"
}
disabled={bekliyor || kredi < 1}
maxLength={2000}
className="h-11 flex-1"
/>
{kredi < 1 ? (
<Button
asChild
className="h-11 cursor-pointer bg-orange-500 px-4 text-white hover:bg-orange-600"
>
<Link href="/paket">Kredi yükle</Link>
</Button>
) : (
<Button
type="submit"
disabled={bekliyor || !girdi.trim()}
className="h-11 cursor-pointer bg-orange-500 px-4 text-white transition-colors duration-200 hover:bg-orange-600"
aria-label="Gönder"
>
{bekliyor ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<SendHorizonal className="size-4" aria-hidden />
)}
</Button>
)}
</form>
</div>
);
}

View File

@@ -1,182 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { cities } from "turkey-map-react/lib/data";
export type IlOzeti = {
hayal: number;
dengeli: number;
garanti: number;
};
type Props = {
/** DB'deki il adıyla (büyük harf Türkçe) program sayıları */
iller: Record<string, IlOzeti>;
sira: number;
turKey: string;
seciliIl: string | null;
};
// Harita adını DB formatına çevirir: "Hakkâri" → "HAKKARİ"
function normalizeIl(name: string): string {
return name
.toLocaleUpperCase("tr-TR")
.replace(/Â/g, "A")
.replace(/Î/g, "İ")
.replace(/Û/g, "U");
}
function toplam(o: IlOzeti): number {
return o.hayal + o.dengeli + o.garanti;
}
type Secim = {
name: string;
ozet: IlOzeti;
x: number;
y: number;
};
export function BolgeHaritasi({ iller, sira, turKey, seciliIl }: Props) {
const [secim, setSecim] = useState<Secim | null>(null);
const kapsayici = useRef<HTMLDivElement>(null);
const router = useRouter();
function filtrele(ilKey: string) {
const url =
ilKey === seciliIl
? `/sonuc?sira=${sira}&tur=${turKey}`
: `/sonuc?sira=${sira}&tur=${turKey}&il=${encodeURIComponent(ilKey)}`;
router.push(url, { scroll: false });
}
const maxToplam = Math.max(1, ...Object.values(iller).map((o) => toplam(o)));
// Haritada karşılığı olmayan yerler (KKTC, yurtdışı kampüsler)
const haritaIlleri = new Set(cities.map((c) => normalizeIl(c.name)));
const haritaDisi = Object.entries(iller).filter(
([il]) => !haritaIlleri.has(il)
);
function sec(el: SVGPathElement, name: string, ozet: IlOzeti) {
const kutu = kapsayici.current?.getBoundingClientRect();
const r = el.getBoundingClientRect();
if (!kutu) return;
setSecim({
name,
ozet,
x: r.x - kutu.x + r.width / 2,
y: r.y - kutu.y,
});
}
return (
<div ref={kapsayici} className="relative">
<svg
viewBox="0 0 1050 585"
className="h-auto w-full"
role="img"
aria-label="Önerilen programların illere dağılımı haritası"
onClick={(e) => {
if ((e.target as Element).tagName !== "path") setSecim(null);
}}
>
{/* Seçili il en son çizilir ki turuncu konturu komşu iller örtmesin */}
{[...cities]
.sort((a, b) =>
(normalizeIl(a.name) === seciliIl ? 1 : 0) -
(normalizeIl(b.name) === seciliIl ? 1 : 0)
)
.map((city) => {
const key = normalizeIl(city.name);
const ozet = iller[key];
const yogunluk = ozet ? toplam(ozet) / maxToplam : 0;
const secili = key === seciliIl;
return (
<path
key={city.id}
d={city.path}
className={
ozet
? "cursor-pointer transition-opacity duration-200 hover:opacity-80"
: "fill-slate-200 stroke-white"
}
style={
ozet
? {
fill: `oklch(0.623 0.188 259.8 / ${0.3 + 0.7 * yogunluk})`,
stroke: secili ? "oklch(0.705 0.213 47.6)" : "white",
strokeWidth: secili ? 2.5 : 1,
}
: undefined
}
strokeWidth={1}
onClick={
ozet ? () => filtrele(key) : () => setSecim(null)
}
onMouseEnter={
ozet
? (e) => sec(e.currentTarget, city.name, ozet)
: undefined
}
onMouseLeave={ozet ? () => setSecim(null) : undefined}
>
<title>
{ozet
? `${city.name}: ${toplam(ozet)} program — filtrelemek için tıkla`
: city.name}
</title>
</path>
);
})}
</svg>
{secim ? (
<div
data-testid="harita-tooltip"
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm shadow-md"
style={{ left: secim.x, top: secim.y - 6 }}
>
<p className="font-heading font-semibold">{secim.name}</p>
<p className="text-slate-600">
{toplam(secim.ozet)} program
<span className="block text-xs">
{secim.ozet.hayal} hayal · {secim.ozet.dengeli} dengeli ·{" "}
{secim.ozet.garanti} garanti
</span>
<span className="block text-xs font-medium text-primary">
{normalizeIl(secim.name) === seciliIl
? "Filtreyi kaldırmak için tıkla"
: "Filtrelemek için tıkla"}
</span>
</p>
</div>
) : null}
<div className="mt-2 flex items-center justify-end gap-2 text-xs text-slate-500">
<span>az</span>
<span
aria-hidden
className="h-2 w-24 rounded-full"
style={{
background:
"linear-gradient(to right, oklch(0.623 0.188 259.8 / 0.3), oklch(0.623 0.188 259.8))",
}}
/>
<span>çok program</span>
</div>
{haritaDisi.length > 0 ? (
<p className="mt-3 text-xs text-slate-500">
Harita dışı:{" "}
{haritaDisi
.map(
([il, ozet]) => `${il.toLocaleLowerCase("tr-TR")} (${toplam(ozet)})`
)
.join(", ")}
</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
// motion-primitives TextLoop — https://motion-primitives.com/docs/text-loop
import { cn } from "@/lib/utils";
import {
AnimatePresence,
motion,
type Transition,
type Variants,
} from "motion/react";
import { Children, useEffect, useState } from "react";
export type TextLoopProps = {
children: React.ReactNode[];
className?: string;
interval?: number;
transition?: Transition;
variants?: Variants;
onIndexChange?: (index: number) => void;
};
const varsayilanVaryantlar: Variants = {
initial: { y: 20, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -20, opacity: 0 },
};
export function TextLoop({
children,
className,
interval = 2,
transition = { duration: 0.3 },
variants,
onIndexChange,
}: TextLoopProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const items = Children.toArray(children);
useEffect(() => {
const intervalMs = interval * 1000;
const timer = setInterval(() => {
setCurrentIndex((current) => {
const next = (current + 1) % items.length;
onIndexChange?.(next);
return next;
});
}, intervalMs);
return () => clearInterval(timer);
}, [items.length, interval, onIndexChange]);
return (
<div className={cn("relative inline-block whitespace-nowrap", className)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={currentIndex}
initial="initial"
animate="animate"
exit="exit"
transition={transition}
variants={variants ?? varsayilanVaryantlar}
>
{items[currentIndex]}
</motion.div>
</AnimatePresence>
</div>
);
}

View File

@@ -48,7 +48,9 @@ export function HeroForm() {
}
}
// Seçimler tamam: sonuç sayfası (ve sonrası) kullanabilsin diye sakla, yönlendir.
// Seçimler tamam: sakla ve giriş kapısına yönlendir. Girişli kullanıcıyı
// /giris zaten callback'e (sonuç + otomatik üretim) sektirir; girişsiz
// kullanıcı listeyi görmeden önce funnel'lı giriş ekranıyla karşılaşır.
function secimleriTamamla(secimler: SihirbazSecimleri) {
if (!sira) return;
try {
@@ -58,7 +60,8 @@ export function HeroForm() {
);
} catch {}
setModalAcik(false);
router.push(`/sonuc?sira=${sira}&tur=${TUR}`);
const geri = `/sonuc?sira=${sira}&tur=${TUR}&sihirbaz=1`;
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
}
return (

View File

@@ -0,0 +1,96 @@
"use client";
// Sihirbazın "Nerede okumak istersin?" adımı: çip listesi yerine dokunarak
// il seçilen Türkiye haritası. Mavi ton ildeki program sayısını, turuncu
// kontur seçimi gösterir. KKTC gibi harita dışı yerler çip olarak eklenir.
import { cities } from "turkey-map-react/lib/data";
import { normalizeIlAdi } from "@/lib/harita";
export function IlSecimHaritasi({
iller,
secili,
onToggle,
}: {
/** Bu sıralama penceresinde programı olan iller (DB biçimi ad + adet) */
iller: { il: string; adet: number }[];
secili: string[];
onToggle: (il: string) => void;
}) {
const adetler = new Map(iller.map((i) => [i.il, i.adet]));
const maxAdet = Math.max(1, ...iller.map((i) => i.adet));
const seciliSet = new Set(secili);
return (
<div>
<svg
viewBox="0 0 1050 585"
className="h-auto w-full"
role="group"
aria-label="Okumak istediğin illeri haritadan seç"
>
{/* Seçili iller en son çizilir ki turuncu kontur komşularca örtülmesin */}
{[...cities]
.sort(
(a, b) =>
Number(seciliSet.has(normalizeIlAdi(a.name))) -
Number(seciliSet.has(normalizeIlAdi(b.name))),
)
.map((city) => {
const key = normalizeIlAdi(city.name);
const adet = adetler.get(key);
const seciliMi = seciliSet.has(key);
const yogunluk = adet ? adet / maxAdet : 0;
return (
<path
key={city.id}
d={city.path}
className={
adet
? "cursor-pointer transition-opacity duration-200 hover:opacity-75"
: undefined
}
fill={
seciliMi
? "oklch(0.705 0.213 47.6)" // orange-500
: adet
? `oklch(0.623 0.188 259.8 / ${0.25 + 0.65 * yogunluk})`
: "oklch(0.929 0.013 255.5)" // slate-200
}
stroke={seciliMi ? "oklch(0.553 0.195 38.4)" : "white"}
strokeWidth={seciliMi ? 2 : 1}
onClick={adet ? () => onToggle(key) : undefined}
role={adet ? "button" : undefined}
aria-pressed={adet ? seciliMi : undefined}
>
<title>
{adet
? `${city.name}: ${adet} program — ${seciliMi ? "seçimi kaldır" : "seçmek için tıkla"}`
: `${city.name}: bu sıralamada program yok`}
</title>
</path>
);
})}
</svg>
<div className="mt-2 flex items-center justify-end gap-2 text-xs text-slate-500">
<span>az</span>
<span
aria-hidden
className="h-2 w-24 rounded-full"
style={{
background:
"linear-gradient(to right, oklch(0.623 0.188 259.8 / 0.25), oklch(0.623 0.188 259.8 / 0.9))",
}}
/>
<span>çok program</span>
<span className="ml-3 inline-flex items-center gap-1.5">
<span
aria-hidden
className="size-2.5 rounded-full bg-orange-500"
/>
seçili
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,411 @@
"use client";
// Manuel 24'lük listenin alt çekmecesi (bottom sheet) + "+"dan listeye uçan
// pill katmanı. Layout'ta bir kez monte edilir; navbar'daki Listem butonu
// açar/kapatır.
//
// Motion dili (apple-design / emil-design-eng):
// - Sheet alttan spring ile yükselir; çıkış girişten hızlı, iOS drawer eğrisi.
// - Üstteki tutamak çubuğundan aşağı çekilince kapanır (offset veya hız eşiği).
// - Satır sıralama yalnız grip tutamağından başlar (dragControls); gövdeyi
// sağa kaydırmak silme jesti — eşik aşılırsa satır uçar, altından kırmızı
// sil bandı görünür.
// - Uçuş pill'i momentumsuz doğduğu için sekmeden (bounce ~0) hedefe süzülür.
// - prefers-reduced-motion: uçuş yok, sheet cross-fade; silme için X butonu
// her durumda kalır.
import { useEffect, useState } from "react";
import Link from "next/link";
import {
GripVertical,
ListChecks,
Sparkles,
Trash2,
X,
} from "lucide-react";
import {
AnimatePresence,
motion,
Reorder,
useDragControls,
useMotionValue,
useReducedMotion,
useTransform,
type PanInfo,
} from "motion/react";
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
import {
MANUEL_LISTE_MAX,
cekmeceKapat,
cikar,
temizle,
ucusAbone,
useCekmeceAcik,
useManuelListe,
yenidenSirala,
type ManuelTercih,
type UcusIstegi,
} from "./store";
// Risk renk dili ürünün geri kalanıyla birebir aynı (bkz. rapor-listesi)
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" },
"az-riskli": { nokta: "bg-amber-500", kenar: "border-l-amber-500" },
riskli: { nokta: "bg-red-500", kenar: "border-l-red-500" },
};
export function ListeCekmecesi() {
const liste = useManuelListe();
const acik = useCekmeceAcik();
const azMotion = useReducedMotion();
const sheetKontrol = useDragControls();
// Escape ile kapat — çekmece modal değil, akışı bölmeden yaşar
useEffect(() => {
if (!acik) return;
const dinle = (e: KeyboardEvent) => {
if (e.key === "Escape") cekmeceKapat();
};
window.addEventListener("keydown", dinle);
return () => window.removeEventListener("keydown", dinle);
}, [acik]);
const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100);
const surukleyinceKapat = (_: unknown, info: PanInfo) => {
if (info.offset.y > 80 || info.velocity.y > 500) cekmeceKapat();
};
return (
<>
<UcusKatmani />
<AnimatePresence>
{acik ? (
<motion.aside
key="cekmece"
initial={azMotion ? { opacity: 0 } : { y: "calc(100% + 16px)" }}
animate={azMotion ? { opacity: 1 } : { y: 0 }}
exit={
azMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
y: "calc(100% + 16px)",
transition: {
duration: 0.25,
ease: [0.32, 0.72, 0, 1],
},
}
}
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
// layoutRoot: sheet'in kendi y-transform animasyonu, içindeki
// Reorder satırlarının layout ölçümünü bozmasın (satırlar aksi
// halde giriş anındaki offset'i kalıcı transform olarak taşıyor)
layoutRoot
drag={azMotion ? false : "y"}
dragListener={false}
dragControls={sheetKontrol}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.6 }}
onDragEnd={surukleyinceKapat}
aria-label="Manuel tercih listem"
className="fixed inset-x-0 bottom-0 z-[60] flex max-h-[85dvh] w-full justify-center p-3"
>
<div className="flex min-h-0 w-full max-w-lg flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl">
{/* Tutamak: buradan aşağı çekilince sheet kapanır */}
<div
onPointerDown={(e) => {
if (!azMotion) sheetKontrol.start(e);
}}
className="flex cursor-grab touch-none justify-center pb-1 pt-3 active:cursor-grabbing"
>
<div className="h-1 w-10 rounded-full bg-slate-300" aria-hidden />
</div>
{/* Başlık + doluluk */}
<div className="border-b border-slate-100 px-5 pb-4 pt-1">
<div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-heading text-lg font-bold">
<ListChecks className="size-5 text-primary" aria-hidden />
Listem
<span
data-cekmece-hedef
className="font-heading text-sm font-bold tabular-nums text-slate-400"
>
{liste.length}/{MANUEL_LISTE_MAX}
</span>
</h2>
<div className="flex items-center gap-1">
{liste.length > 0 ? (
<button
type="button"
onClick={temizle}
title="Listeyi temizle"
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-red-500"
>
<Trash2 className="size-4" aria-hidden />
<span className="sr-only">Listeyi temizle</span>
</button>
) : null}
<button
type="button"
onClick={cekmeceKapat}
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
>
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</button>
</div>
</div>
<div
className="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-100"
role="progressbar"
aria-valuenow={liste.length}
aria-valuemin={0}
aria-valuemax={MANUEL_LISTE_MAX}
aria-label="Liste doluluğu"
>
<motion.div
className="h-full rounded-full bg-primary"
animate={{ width: `${doluluk}%` }}
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
/>
</div>
<p className="mt-2 text-xs text-slate-500">
Sıralamanla ulaşabildiğin programlardan kendi listen
tutamaktan sürükleyip sırala, sağa kaydırıp sil.
</p>
</div>
{/* Gövde: sürüklenebilir satırlar */}
{liste.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 px-8 py-12 text-center">
<ListChecks className="size-8 text-slate-300" aria-hidden />
<p className="font-heading text-sm font-bold text-slate-700">
Listen henüz boş
</p>
<p className="text-xs leading-relaxed text-slate-500">
Sonuç sayfasındaki tablodan <span aria-hidden></span> ile
program ekle; 24 tercihlik listen burada birikir.
</p>
</div>
) : (
<Reorder.Group
axis="y"
values={liste}
onReorder={yenidenSirala}
layoutScroll
as="ol"
className="min-h-0 flex-1 space-y-2 overflow-y-auto px-4 py-4"
>
<AnimatePresence initial={false}>
{liste.map((t, i) => (
<CekmeceSatiri key={t.id} tercih={t} sira={i + 1} />
))}
</AnimatePresence>
</Reorder.Group>
)}
{/* Alt bant: AI danışman köprüsü */}
<div className="border-t border-slate-100 px-5 py-4">
<Link
href="/listem"
onClick={cekmeceKapat}
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary transition-colors duration-200 hover:text-primary/80"
>
<Sparkles className="size-4" aria-hidden />
AI listeni ve danışmanı
</Link>
</div>
</div>
</motion.aside>
) : null}
</AnimatePresence>
</>
);
}
/** Sağa kaydırıp silme eşiği (px) — bunu aşınca satır listeden çıkar */
const SIL_ESIK = 90;
function CekmeceSatiri({
tercih,
sira,
}: {
tercih: ManuelTercih;
sira: number;
}) {
const stil = tercih.risk ? RISK_STIL[tercih.risk] : null;
const siraKontrol = useDragControls();
// Kartın yatay konumu: sil bandının görünürlüğü buna bağlı
const x = useMotionValue(0);
const silGorunur = useTransform(x, [0, SIL_ESIK / 2], [0, 1]);
const kaydirinca = (_: unknown, info: PanInfo) => {
if (
info.offset.x > SIL_ESIK ||
(info.offset.x > 32 && info.velocity.x > 500)
) {
cikar(tercih.id);
}
};
return (
<Reorder.Item
value={tercih}
as="li"
layout="position"
layoutId={`manuel-tercih-${tercih.id}`}
dragListener={false}
dragControls={siraKontrol}
initial={{ opacity: 0, y: 8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, x: 96, transition: { duration: 0.18 } }}
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
whileDrag={{
scale: 1.03,
boxShadow: "0 12px 32px rgba(15, 23, 42, 0.16)",
zIndex: 10,
}}
className="relative select-none"
>
{/* Sil bandı: kart sağa kaydıkça altından görünür */}
<motion.div
style={{ opacity: silGorunur }}
className="absolute inset-0 flex items-center rounded-xl bg-red-500 pl-4"
aria-hidden
>
<Trash2 className="size-4 text-white" />
</motion.div>
{/* Kart gövdesi: sağa kaydırılabilir (sola kilitli) */}
<motion.div
drag="x"
style={{ x }}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={{ left: 0, right: 0.7 }}
dragSnapToOrigin
onDragEnd={kaydirinca}
className={`relative flex items-center gap-2.5 rounded-xl border border-l-4 border-slate-200 bg-white px-3 py-2.5 ${
stil?.kenar ?? "border-l-slate-300"
}`}
>
<button
type="button"
onPointerDown={(e) => {
e.stopPropagation(); // yatay kaydırmayı tetiklemesin
siraKontrol.start(e);
}}
className="-m-1 shrink-0 cursor-grab touch-none rounded p-1 text-slate-300 active:cursor-grabbing"
>
<GripVertical className="size-4" aria-hidden />
<span className="sr-only">{tercih.isim} sırasını değiştir</span>
</button>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-[11px] font-bold tabular-nums">
{sira}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">
{tercih.isim}
</span>
<span className="block truncate text-xs text-slate-500">
{tercih.universite}
{tercih.il ? ` · ${tercih.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
</span>
{tercih.efektifSira != null ? (
<span className="shrink-0 text-xs tabular-nums text-slate-500">
~{tercih.efektifSira.toLocaleString("tr-TR")}.
</span>
) : null}
{tercih.risk ? (
<span
className={`size-2.5 shrink-0 rounded-full ${RISK_STIL[tercih.risk].nokta}`}
aria-label={RISK_ETIKET[tercih.risk]}
/>
) : null}
<button
type="button"
onClick={() => cikar(tercih.id)}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
className="shrink-0 cursor-pointer rounded-full p-1.5 text-slate-300 transition-colors duration-200 hover:bg-slate-100 hover:text-red-500"
>
<X className="size-3.5" aria-hidden />
<span className="sr-only">{tercih.isim} listeden çıkar</span>
</button>
</motion.div>
</Reorder.Item>
);
}
// ---------------------------------------------------------------------------
// Uçuş katmanı: "+" butonundan Listem hedefine süzülen pill
// ---------------------------------------------------------------------------
type Ucus = UcusIstegi & { id: number; hedef: { x: number; y: number } };
let ucusSayac = 0;
/** Hedef nokta: çekmece açıksa içindeki sayaç, değilse navbar'daki buton. */
function hedefBul(): { x: number; y: number } | null {
const el =
document.querySelector("[data-cekmece-hedef]") ??
document.querySelector("[data-listem-hedef]");
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
function UcusKatmani() {
const [ucuslar, setUcuslar] = useState<Ucus[]>([]);
const azMotion = useReducedMotion();
useEffect(() => {
if (azMotion) return; // reduced motion: rozet pulse yeter
return ucusAbone((istek) => {
const hedef = hedefBul();
if (!hedef) return;
setUcuslar((u) => [...u, { ...istek, id: ucusSayac++, hedef }]);
});
}, [azMotion]);
if (ucuslar.length === 0) return null;
return (
<div
className="pointer-events-none fixed inset-0 z-[70] print:hidden"
aria-hidden
>
{ucuslar.map((u) => (
<motion.div
key={u.id}
initial={{ x: u.kaynak.x, y: u.kaynak.y, scale: 1, opacity: 1 }}
animate={{
x: u.hedef.x,
y: u.hedef.y,
scale: 0.3,
opacity: 0.4,
}}
transition={{ type: "spring", duration: 0.55, bounce: 0 }}
onAnimationComplete={() =>
setUcuslar((mevcut) => mevcut.filter((x) => x.id !== u.id))
}
className="absolute left-0 top-0"
>
{/* Merkezleme iç sarmalayıcıda: motion'ın x/y transformu ile çakışmasın */}
<div className="flex max-w-56 -translate-x-1/2 -translate-y-1/2 items-center gap-2 rounded-full border border-slate-200 bg-white py-1.5 pl-3 pr-4 shadow-lg">
{u.tercih.risk ? (
<span
className={`size-2 shrink-0 rounded-full ${RISK_STIL[u.tercih.risk].nokta}`}
/>
) : null}
<span className="truncate text-xs font-semibold text-slate-700">
{u.tercih.isim}
</span>
</div>
</motion.div>
))}
</div>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
// Navbar'daki "Listem" butonu: manuel listenin sayacını taşır, tıklanınca
// çekmeceyi açar/kapatır. Uçuş animasyonunun kapalı-çekmece hedefi de budur
// (data-listem-hedef). Sayaç değişince rozet spring ile "pulse" yapar.
import { ListChecks } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
MANUEL_LISTE_MAX,
cekmeceDegistir,
useCekmeceAcik,
useManuelListe,
} from "./store";
export function ListemButonu() {
const liste = useManuelListe();
const acik = useCekmeceAcik();
const azMotion = useReducedMotion();
return (
<button
type="button"
data-listem-hedef
onClick={cekmeceDegistir}
aria-expanded={acik}
aria-label={`Listem — ${liste.length}/${MANUEL_LISTE_MAX} tercih`}
title="24'lük tercih listen"
className="relative inline-flex h-12 cursor-pointer items-center gap-2 whitespace-nowrap rounded-full border border-slate-200 bg-white px-5 text-sm font-semibold text-slate-700 transition-[color,background-color,border-color,transform] duration-200 hover:border-slate-300 hover:bg-slate-50 active:scale-[0.97]"
>
<ListChecks className="size-4 text-primary" aria-hidden />
<span className="hidden sm:inline">Listem</span>
{liste.length > 0 ? (
// key=length: her eklemede rozet yeniden doğar ve spring ile oturur
<motion.span
key={liste.length}
initial={azMotion ? false : { scale: 0.6 }}
animate={{ scale: 1 }}
transition={{ type: "spring", duration: 0.4, bounce: 0.45 }}
className="flex size-5 items-center justify-center rounded-full bg-orange-500 font-heading text-[11px] font-bold tabular-nums text-white"
>
{liste.length}
</motion.span>
) : null}
</button>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
// Manuel 24'lük listenin coğrafyası: tablodan "+" ile eklenen her programın
// üniversitesi gerçek kampüs konumunda risk renkli pinlenir. Liste store'una
// abonedir; ekleme/çıkarma/sürükleme anında haritaya yansır.
import { useMemo, useState } from "react";
import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi";
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
import type { RiskSeviyesi } from "@/lib/risk";
import { useManuelListe } from "./store";
// Aynı üniversitede birden çok tercih varsa pinde kötü risk baskın olsun
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
guvenli: 0,
"az-riskli": 1,
riskli: 2,
};
export function ManuelHarita() {
const liste = useManuelListe();
const [seciliIl, setSeciliIl] = useState<string | null>(null);
const { pinler, haritaDisi } = useMemo(() => {
const gruplar = new Map<string, HaritaPin>();
let haritaDisi = 0;
liste.forEach((t, i) => {
const konum = uniKonum(t.universite, t.il) ?? ilKonum(t.il);
if (!konum) {
haritaDisi += 1;
return;
}
const risk = t.risk ?? "az-riskli";
const key = `${uniAdiNormalize(t.universite)}|${t.il ?? ""}`;
const mevcut = gruplar.get(key);
if (mevcut) {
mevcut.tercihSiralari.push(i + 1);
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
} else {
gruplar.set(key, {
id: key,
universite: t.universite,
il: t.il,
x: konum.x,
y: konum.y,
tercihSiralari: [i + 1],
risk,
});
}
});
return { pinler: [...gruplar.values()], haritaDisi };
}, [liste]);
return (
<div>
<TercihHaritasi
pinler={pinler}
seciliIl={seciliIl}
onSeciliIlDegisti={setSeciliIl}
/>
{haritaDisi > 0 ? (
<p className="mt-1 px-1 text-xs text-slate-500">
{haritaDisi} tercih harita dışında (KKTC vb.)
</p>
) : null}
{liste.length === 0 ? (
<p className="mt-2 text-center text-xs text-slate-500">
Tablodan <span aria-hidden></span> ile ekledikçe üniversiteler
haritada belirir.
</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,168 @@
"use client";
// Manuel 24'lük liste — AI'dan bağımsız, kullanıcının tablodan "+" ile kendi
// kurduğu tercih listesi. Kaynak tek: bu modüldeki harici store. Navbar rozeti,
// çekmece ve program tablosu aynı store'a abone olur; kalıcılık localStorage'ta
// (girişsiz kullanıcı da liste kurabilsin diye sunucuya yazmıyoruz).
import { useSyncExternalStore } from "react";
import type { RiskSeviyesi } from "@/lib/risk";
export const MANUEL_LISTE_MAX = 24;
const STORAGE_KEY = "kolaytercih.manuel-liste.v1";
export type ManuelTercih = {
/** yokatlas program id'si */
id: string;
isim: string;
universite: string;
il: string | null;
efektifSira: number | null;
/** Ekleme anındaki aday sıralamasına göre risk (renklendirme için) */
risk: RiskSeviyesi | null;
};
/** "+" butonundan listeye uçan pill'in tek seferlik isteği */
export type UcusIstegi = {
tercih: ManuelTercih;
/** Kaynak butonun viewport koordinatları */
kaynak: { x: number; y: number };
};
// SSR snapshot'ı: her çağrıda aynı referans dönmeli, yoksa React döngüye girer
const BOS_LISTE: ManuelTercih[] = [];
let liste: ManuelTercih[] = BOS_LISTE;
let cekmeceAcik = false;
let yuklendi = false;
const dinleyiciler = new Set<() => void>();
const ucusDinleyiciler = new Set<(istek: UcusIstegi) => void>();
function yayinla() {
for (const cb of dinleyiciler) cb();
}
function kaydet() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(liste));
} catch {}
}
function dogrula(ham: unknown): ManuelTercih[] {
if (!Array.isArray(ham)) return [];
return ham
.filter(
(t): t is ManuelTercih =>
typeof t === "object" &&
t !== null &&
typeof (t as ManuelTercih).id === "string" &&
typeof (t as ManuelTercih).isim === "string",
)
.slice(0, MANUEL_LISTE_MAX);
}
function yukle() {
if (yuklendi || typeof window === "undefined") return;
yuklendi = true;
try {
const ham = localStorage.getItem(STORAGE_KEY);
if (ham) liste = dogrula(JSON.parse(ham));
} catch {}
// Başka sekmede yapılan değişiklikleri de yansıt
window.addEventListener("storage", (e) => {
if (e.key !== STORAGE_KEY) return;
try {
liste = e.newValue ? dogrula(JSON.parse(e.newValue)) : [];
yayinla();
} catch {}
});
}
function abone(cb: () => void) {
dinleyiciler.add(cb);
if (!yuklendi) {
// İlk client abonesi: storage'ı oku ve snapshot'ı tazele
yukle();
queueMicrotask(yayinla);
}
return () => {
dinleyiciler.delete(cb);
};
}
export function useManuelListe(): ManuelTercih[] {
return useSyncExternalStore(
abone,
() => liste,
() => BOS_LISTE,
);
}
export function useCekmeceAcik(): boolean {
return useSyncExternalStore(
abone,
() => cekmeceAcik,
() => false,
);
}
export function listedeMi(id: string): boolean {
return liste.some((t) => t.id === id);
}
/** Listeye ekler; doluysa veya zaten varsa false döner. */
export function ekle(tercih: ManuelTercih): boolean {
yukle();
if (liste.length >= MANUEL_LISTE_MAX || listedeMi(tercih.id)) return false;
liste = [...liste, tercih];
kaydet();
yayinla();
return true;
}
export function cikar(id: string) {
liste = liste.filter((t) => t.id !== id);
kaydet();
yayinla();
}
/** Sürükle-bırak sonrası yeni sıra (motion Reorder'dan gelir) */
export function yenidenSirala(yeni: ManuelTercih[]) {
liste = yeni;
kaydet();
yayinla();
}
export function temizle() {
liste = [];
kaydet();
yayinla();
}
export function cekmeceAc() {
cekmeceAcik = true;
yayinla();
}
export function cekmeceKapat() {
cekmeceAcik = false;
yayinla();
}
export function cekmeceDegistir() {
cekmeceAcik = !cekmeceAcik;
yayinla();
}
/** "+" butonu uçuş animasyonu tetikler; katman (liste-cekmecesi) dinler. */
export function ucusBaslat(istek: UcusIstegi) {
for (const cb of ucusDinleyiciler) cb(istek);
}
export function ucusAbone(cb: (istek: UcusIstegi) => void) {
ucusDinleyiciler.add(cb);
return () => {
ucusDinleyiciler.delete(cb);
};
}

View File

@@ -0,0 +1,253 @@
"use client";
// Sonuç sayfasının AI'sız ham listesi: yalnızca başarı sıralamasıyla
// yokatlas'tan gelen programlar, dilim sekmeleri halinde. Her satırın "+"
// butonu programı manuel 24'lük listeye ekler; pill navbar'daki (ya da açık
// çekmecedeki) Listem hedefine uçar (bkz. manuel-liste/liste-cekmecesi).
import { useState } from "react";
import { Check, Plus, Rocket, Scale, ShieldCheck } from "lucide-react";
import type { Program, RankResults } from "@/lib/db";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
MANUEL_LISTE_MAX,
ekle,
ucusBaslat,
useManuelListe,
type ManuelTercih,
} from "@/components/manuel-liste/store";
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
type DilimKey = keyof RankResults;
const DILIMLER: {
key: DilimKey;
etiket: string;
aciklama: string;
icon: typeof Rocket;
renk: string;
}[] = [
{
key: "hayal",
etiket: "Hayal",
aciklama: "Tabanı sıralamanın üstünde — şansını denediğin satırlar.",
icon: Rocket,
renk: "text-red-500",
},
{
key: "dengeli",
etiket: "Dengeli",
aciklama: "Tabanı sıralamana denk — listenin bel kemiği.",
icon: Scale,
renk: "text-amber-500",
},
{
key: "garanti",
etiket: "Garanti",
aciklama: "Tabanı belirgin şekilde altında — güvenli bölge.",
icon: ShieldCheck,
renk: "text-emerald-600",
},
];
const RISK_NOKTA: Record<RiskSeviyesi, string> = {
guvenli: "bg-emerald-500",
"az-riskli": "bg-amber-500",
riskli: "bg-red-500",
};
function efektifSira(p: Program): number | null {
return p.sira2025 ?? p.sira2024;
}
function tercihYap(p: Program, adaySira: number): ManuelTercih {
const taban = efektifSira(p);
return {
id: p.id,
isim: p.isim,
universite: p.universite,
il: p.il,
efektifSira: taban,
risk: riskHesapla(taban, adaySira),
};
}
export function ProgramTablosu({
sonuclar,
adaySira,
}: {
sonuclar: RankResults;
adaySira: number;
}) {
const liste = useManuelListe();
const [aktifDilim, setAktifDilim] = useState<DilimKey>("dengeli");
const listedekiler = new Set(liste.map((t) => t.id));
const doldu = liste.length >= MANUEL_LISTE_MAX;
function eklemeyiDene(p: Program, buton: HTMLElement) {
const tercih = tercihYap(p, adaySira);
if (!ekle(tercih)) return;
const r = buton.getBoundingClientRect();
ucusBaslat({
tercih,
kaynak: { x: r.left + r.width / 2, y: r.top + r.height / 2 },
});
}
return (
<section className="mt-16" aria-label="Sıralamanla açılan programlar">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="font-heading text-2xl font-bold">
Sıralamanla ılan programlar
</h2>
<p className="mt-1 max-w-2xl text-sm text-slate-500">
Yapay zekâsız, ham YÖK Atlas verisi: geçen yılın taban
sıralamalarına göre ulaşabildiğin bölümler.{" "}
<span className="font-medium text-slate-700">
ile beğendiklerini kendi 24&apos;lük listene at.
</span>
</p>
</div>
</div>
{/* Listendeki üniversitelerin konumu — ekledikçe canlı dolar */}
<div className="mt-5">
<ManuelHarita />
</div>
<Tabs
value={aktifDilim}
onValueChange={(v) => setAktifDilim(v as DilimKey)}
className="mt-5"
>
<TabsList>
{DILIMLER.map((d) => (
<TabsTrigger key={d.key} value={d.key} className="cursor-pointer">
<d.icon className={`size-3.5 ${d.renk}`} aria-hidden />
{d.etiket}
<span className="text-xs tabular-nums text-slate-400">
{sonuclar[d.key].length}
</span>
</TabsTrigger>
))}
</TabsList>
{DILIMLER.map((d) => (
<TabsContent key={d.key} value={d.key}>
<p className="mt-2 text-xs text-slate-500">{d.aciklama}</p>
<div className="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
{sonuclar[d.key].length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-slate-500">
Bu dilimde filtrene uyan program bulunamadı.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead>Program</TableHead>
<TableHead className="text-right">Taban sıra</TableHead>
<TableHead className="w-16 text-right">
<span className="sr-only">Listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sonuclar[d.key].map((p) => (
<ProgramSatiri
key={p.id}
program={p}
adaySira={adaySira}
listede={listedekiler.has(p.id)}
doldu={doldu}
onEkle={eklemeyiDene}
/>
))}
</TableBody>
</Table>
)}
</div>
</TabsContent>
))}
</Tabs>
</section>
);
}
function ProgramSatiri({
program: p,
adaySira,
listede,
doldu,
onEkle,
}: {
program: Program;
adaySira: number;
listede: boolean;
doldu: boolean;
onEkle: (p: Program, buton: HTMLElement) => void;
}) {
const taban = efektifSira(p);
const risk = riskHesapla(taban, adaySira);
return (
<TableRow>
<TableCell className="pl-4 pr-0">
{risk ? (
<span
className={`block size-2.5 rounded-full ${RISK_NOKTA[risk]}`}
aria-hidden
/>
) : null}
</TableCell>
<TableCell className="max-w-0 w-full">
<span className="block truncate text-sm font-semibold">{p.isim}</span>
<span className="block truncate text-xs text-slate-500">
{p.universite}
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
</TableCell>
<TableCell className="pr-4 text-right">
<button
type="button"
disabled={listede || doldu}
onClick={(e) => onEkle(p, e.currentTarget)}
title={
listede
? "Zaten listende"
: doldu
? `Liste dolu (${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX})`
: "Listeme ekle"
}
className={`inline-flex size-8 cursor-pointer items-center justify-center rounded-full border transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.92] disabled:cursor-default ${
listede
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-slate-200 bg-white text-slate-500 hover:border-primary hover:bg-primary/5 hover:text-primary disabled:opacity-40"
}`}
>
{listede ? (
<Check className="size-4" aria-hidden />
) : (
<Plus className="size-4" aria-hidden />
)}
<span className="sr-only">
{listede ? `${p.isim} listende` : `${p.isim} listeye ekle`}
</span>
</button>
</TableCell>
</TableRow>
);
}

View File

@@ -1,5 +1,26 @@
import { Rocket, Scale, ShieldCheck } from "lucide-react";
import { Badge } from "@/components/ui/badge";
"use client";
// 24'lük listenin gövdesi — metin diyeti sonrası hali:
// AI cümleleri satırda değil, satıra dokununca açılan detayda yaşar.
// Satır tek bakışta taranır: sıra, program, üniversite·il, taban, trend, risk.
// Strateji şeridi paragraf yerine dilim sayıları + kısa uyarı rozetleri basar.
//
// kilitliBaslangic verilirse o indeksten sonraki satırlar artan blur ile
// kilitlenir ve üzerine kilitOverlay bindirilir (paketsiz önizleme).
// Kilitli satırların içeriği sunucuda zaten maskelidir (bkz. rapor-maske);
// buradaki blur yalnızca görsel dil.
import { useEffect, useRef, useState } from "react";
import {
ChevronDown,
MapPin,
Minus,
Rocket,
Scale,
ShieldCheck,
TrendingDown,
TrendingUp,
} from "lucide-react";
import type { RaporSonuc } from "@/lib/ai/rapor";
import {
RISK_ETIKET,
@@ -15,123 +36,295 @@ export const DILIM_ETIKET: Record<string, string> = {
};
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli
export const RISK_STIL: Record<
const RISK_STIL: Record<
RiskSeviyesi,
{ badge: string; kenar: string; icon: typeof Rocket }
{ nokta: string; kenar: string; icon: typeof Rocket }
> = {
riskli: {
badge: "bg-red-100 text-red-700",
kenar: "border-l-red-500",
icon: Rocket,
},
riskli: { nokta: "bg-red-500", kenar: "border-l-red-500", icon: Rocket },
"az-riskli": {
badge: "bg-amber-100 text-amber-700",
nokta: "bg-amber-500",
kenar: "border-l-amber-500",
icon: Scale,
},
guvenli: {
badge: "bg-emerald-100 text-emerald-700",
nokta: "bg-emerald-500",
kenar: "border-l-emerald-500",
icon: ShieldCheck,
},
};
/**
* 24'lük listenin gövdesi (genel değerlendirme + tercih kartları).
* Hem sonuç modalında hem sohbet sayfasında kullanılır. Risk rengi
* adayın sıralamasına göre deterministik hesaplanır (adaySira verilirse);
* eski raporlarda dilimden türetilir.
*/
export function RaporListesi({
rapor,
adaySira,
}: {
rapor: RaporSonuc;
adaySira?: number;
}) {
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 4-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);
return (
<div>
<div className="rounded-2xl border border-slate-200 bg-white p-6">
<h2 className="font-heading text-lg font-bold">Genel değerlendirme</h2>
<p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-slate-700">
{rapor.genelDegerlendirme}
</p>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-slate-500">
{(["guvenli", "az-riskli", "riskli"] as const).map((r) => (
<span key={r} className="inline-flex items-center gap-1.5">
<span
className={`size-2.5 rounded-full ${
r === "guvenli"
? "bg-emerald-500"
: r === "az-riskli"
? "bg-amber-500"
: "bg-red-500"
}`}
aria-hidden
/>
{RISK_ETIKET[r]}
</span>
))}
</div>
<ol className="mt-3 space-y-3">
{rapor.tercihler.map((t) => {
const p = rapor.programlar[t.programId];
const risk =
(adaySira != null
? riskHesapla(p?.efektifSira, adaySira)
: null) ?? dilimdenRisk(t.dilim);
const stil = RISK_STIL[risk];
return (
<li
key={t.sira}
className={`rounded-2xl border border-l-4 border-slate-200 bg-white p-5 ${stil.kenar}`}
>
<div className="flex flex-wrap items-center gap-3">
<span className="flex size-8 items-center justify-center rounded-full bg-slate-100 font-heading text-sm font-bold">
{t.sira}
</span>
<div className="min-w-0 flex-1">
<p className="font-semibold">{p?.isim ?? t.programId}</p>
<p className="text-sm text-slate-500">
{p?.universite} · {p?.il ?? "—"}
{p?.unitur ? ` · ${p.unitur}` : ""}
{p?.efektifSira != null
? ` · taban ~${p.efektifSira.toLocaleString("tr-TR")}.`
: ""}
</p>
</div>
<Badge variant="secondary" className={stil.badge}>
<stil.icon className="size-3" aria-hidden />
{DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]}
</Badge>
</div>
<dl className="mt-3 grid gap-2 text-sm sm:grid-cols-3">
<div className="rounded-lg bg-slate-50 p-3">
<dt className="text-xs font-semibold text-slate-500">
Neden listede?
</dt>
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
</div>
<div className="rounded-lg bg-slate-50 p-3">
<dt className="text-xs font-semibold text-slate-500">
Risk notu
</dt>
<dd className="mt-1 text-slate-700">{t.riskNotu}</dd>
</div>
<div className="rounded-lg bg-slate-50 p-3">
<dt className="text-xs font-semibold text-slate-500">
4 yıllık trend
</dt>
<dd className="mt-1 text-slate-700">{t.trendOzeti}</dd>
</div>
</dl>
</li>
);
})}
</ol>
<div className="mt-2 flex items-center gap-2">
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-10 w-[150px]"
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>
);
}
export function RaporListesi({
rapor,
adaySira,
kilitliBaslangic,
kilitOverlay,
acikSira,
onHaritadaGor,
}: {
rapor: RaporSonuc;
adaySira?: number;
kilitliBaslangic?: number;
kilitOverlay?: React.ReactNode;
/** Dışarıdan (haritadan) açtırılan satır — değişince satır açılıp kaydırılır */
acikSira?: number | null;
/** Detaydaki "Haritada gör" — il DB biçiminde */
onHaritadaGor?: (il: string | null, sira: number) => void;
}) {
const [acik, setAcik] = useState<Set<number>>(new Set());
const satirRefs = useRef(new Map<number, HTMLLIElement>());
// Haritadan gelen seçim: satırı aç ve görünür alana kaydır
useEffect(() => {
if (acikSira == null) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAcik((mevcut) => new Set(mevcut).add(acikSira));
satirRefs.current
.get(acikSira)
?.scrollIntoView({ behavior: "smooth", block: "center" });
}, [acikSira]);
function tiklandi(sira: number) {
setAcik((mevcut) => {
const yeni = new Set(mevcut);
if (yeni.has(sira)) yeni.delete(sira);
else yeni.add(sira);
return yeni;
});
}
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
const acikTercihler = rapor.tercihler.slice(0, kilitIdx);
// Kilitli alan: tamamını basmaya gerek yok, blur duvarı kısa tutulur
const kilitliTercihler = rapor.tercihler.slice(kilitIdx, kilitIdx + 5);
const satir = (
t: RaporSonuc["tercihler"][number],
secenekler?: { blurPx?: number },
) => {
const p = rapor.programlar[t.programId];
const risk =
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
dilimdenRisk(t.dilim);
const stil = RISK_STIL[risk];
const trend = trendYonu(p?.siraGecmisi);
const trendG = trend ? TREND_GORUNUM[trend] : null;
const acikMi = acik.has(t.sira) && !secenekler?.blurPx;
return (
<li
key={t.sira}
ref={(el) => {
if (el) satirRefs.current.set(t.sira, el);
else satirRefs.current.delete(t.sira);
}}
style={
secenekler?.blurPx
? { filter: `blur(${secenekler.blurPx}px)` }
: undefined
}
className={`rounded-xl border border-l-4 border-slate-200 bg-white ${stil.kenar}`}
>
<button
type="button"
onClick={() => tiklandi(t.sira)}
aria-expanded={acikMi}
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left"
>
<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="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">
{p?.isim ?? t.programId}
</span>
<span className="block truncate text-xs text-slate-500">
{p?.universite}
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
</span>
<span className="hidden shrink-0 text-xs tabular-nums text-slate-500 sm:block">
{p?.efektifSira != null
? `~${p.efektifSira.toLocaleString("tr-TR")}.`
: ""}
</span>
{trendG ? (
<trendG.icon
className={`size-4 shrink-0 ${trendG.renk}`}
aria-label={trendG.etiket}
/>
) : null}
<span
className={`size-2.5 shrink-0 rounded-full ${stil.nokta}`}
aria-label={`${DILIM_ETIKET[t.dilim]} · ${RISK_ETIKET[risk]}`}
/>
<ChevronDown
className={`size-4 shrink-0 text-slate-400 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
aria-hidden
/>
</button>
{acikMi ? (
<div className="border-t border-slate-100 px-4 py-3">
<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[t.dilim]} · {RISK_ETIKET[risk]}
</span>
{p?.unitur ? <span>· {p.unitur}</span> : null}
{p?.efektifSira != null ? (
<span className="sm:hidden">
· taban ~{p.efektifSira.toLocaleString("tr-TR")}.
</span>
) : null}
</div>
<dl className="grid gap-2 text-sm sm:grid-cols-3">
<div className="rounded-lg bg-slate-50 p-3">
<dt className="text-xs font-semibold text-slate-500">
Neden listede?
</dt>
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
</div>
<div className="rounded-lg bg-slate-50 p-3">
<dt className="text-xs font-semibold text-slate-500">
Risk notu
</dt>
<dd className="mt-1 text-slate-700">{t.riskNotu}</dd>
</div>
<div className="rounded-lg bg-slate-50 p-3">
<dt className="text-xs font-semibold text-slate-500">
4 yıllık trend
</dt>
<dd className="mt-1 text-slate-700">
{t.trendOzeti}
{p?.siraGecmisi ? (
<TrendCizgisi gecmis={p.siraGecmisi} />
) : null}
</dd>
</div>
</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>
) : null}
</li>
);
};
return (
<div>
<p className="text-xs text-slate-500">
Satıra dokun; gerekçe, risk notu ve 4 yıllık trend ılsın.
</p>
<ol className="mt-2 space-y-2">{acikTercihler.map((t) => satir(t))}</ol>
{kilitliTercihler.length > 0 ? (
<div className="relative mt-2">
<ol
className="pointer-events-none max-h-[24rem] select-none space-y-2 overflow-hidden"
aria-hidden
>
{kilitliTercihler.map((t, j) =>
satir(t, { blurPx: Math.min(3 + j * 2, 12) }),
)}
</ol>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-gradient-to-b from-slate-50/20 via-slate-50/75 to-slate-50 px-4">
{kilitOverlay}
</div>
</div>
) : null}
</div>
);
}

View File

@@ -5,9 +5,12 @@
// tamamlanınca onTamamla(secimler) çağırır.
import { useState } from "react";
import { ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
import { ArrowLeft, ArrowRight, Sparkles, X } from "lucide-react";
import { toast } from "sonner";
import { cities } from "turkey-map-react/lib/data";
import { Button } from "@/components/ui/button";
import { IlSecimHaritasi } from "@/components/il-secim-haritasi";
import { normalizeIlAdi } from "@/lib/harita";
import type { SihirbazFacetleri } from "@/lib/db";
import {
ONCELIKLER,
@@ -41,6 +44,9 @@ export function Cip({
);
}
// Haritada karşılığı olan iller (KKTC/yurtdışı kampüsler çip olarak kalır)
const HARITA_ILLERI = new Set(cities.map((c) => normalizeIlAdi(c.name)));
export function SihirbazAdimlar({
facetler,
baslangicSecimler,
@@ -128,20 +134,57 @@ export function SihirbazAdimlar({
Nerede okumak istersin?
</h3>
<p className="mt-1 text-sm text-slate-500">
İstersen boş bırak (fark etmez). En fazla 5 il.
Haritadan dokunarak seç (en fazla 5 il). İstersen boş bırak, fark
etmez.
</p>
<div className="mt-4 flex flex-wrap gap-2">
{facetler.iller.map((i) => (
<Cip
key={i.il}
secili={iller.includes(i.il)}
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{i.il}
<span className="ml-1.5 text-xs opacity-70">{i.adet}</span>
</Cip>
))}
<div className="mt-4">
<IlSecimHaritasi
iller={facetler.iller.filter((i) => HARITA_ILLERI.has(i.il))}
secili={iller}
onToggle={(il) => listeDegistir(iller, setIller, il, 5)}
/>
</div>
{iller.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold text-slate-400">
Seçtiklerin:
</span>
{iller.map((il) => (
<button
key={il}
type="button"
onClick={() => listeDegistir(iller, setIller, il)}
className="inline-flex cursor-pointer items-center gap-1 rounded-full bg-orange-500 px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200 hover:bg-orange-600"
>
{il.toLocaleLowerCase("tr-TR")}
<X className="size-3" aria-hidden />
</button>
))}
</div>
) : null}
{facetler.iller.some((i) => !HARITA_ILLERI.has(i.il)) ? (
<div className="mt-3">
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
Harita dışı (KKTC vb.)
</p>
<div className="flex flex-wrap gap-2">
{facetler.iller
.filter((i) => !HARITA_ILLERI.has(i.il))
.map((i) => (
<Cip
key={i.il}
secili={iller.includes(i.il)}
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{i.il.toLocaleLowerCase("tr-TR")}
<span className="ml-1.5 text-xs opacity-70">
{i.adet}
</span>
</Cip>
))}
</div>
</div>
) : null}
</div>
) : (
<div className="space-y-5">

View File

@@ -0,0 +1,417 @@
"use client";
// Sonuç sayfasının kahraman öğesi: 24 tercihin Türkiye haritası üzerinde
// risk renkli pinlerle gösterimi. Ülke seviyesinde il başına tek toplu pin;
// ile tıklayınca viewBox o ilin sınırlarına animasyonla akar, il büyür ve
// üniversite pinleri gerçek kampüs konumlarında adlarıyla görünür.
// Kilitli (paketsiz) tercihler il merkezinde adsız "?" pini olarak çizilir —
// üniversite adı client'a hiç inmediği için paywall haritadan delinemez.
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { ArrowLeft, Lock } from "lucide-react";
import { cities } from "turkey-map-react/lib/data";
import { Button } from "@/components/ui/button";
import {
HARITA_GENISLIK,
HARITA_YUKSEKLIK,
normalizeIlAdi,
} from "@/lib/harita";
import type { RiskSeviyesi } from "@/lib/risk";
export type HaritaPin = {
/** Üniversite bazlı benzersiz anahtar (kilitlilerde sentetik) */
id: string;
/** Görünen ad — kilitli pinlerde boş bırakılır */
universite: string;
/** DB biçiminde il adı (büyük harf); harita dışıysa null */
il: string | null;
x: number;
y: number;
/** Bu üniversitedeki tercihlerin liste sıraları (1-24) */
tercihSiralari: number[];
risk: RiskSeviyesi;
kilitli?: boolean;
};
const RISK_RENK: Record<RiskSeviyesi, string> = {
guvenli: "oklch(0.696 0.17 162.5)", // emerald-500
"az-riskli": "oklch(0.769 0.188 70.1)", // amber-500
riskli: "oklch(0.637 0.237 25.3)", // red-500
};
// SVG uzayı 0 0 1050 585 ama ülke çizimleri y≈144'te başlıyor; üstteki ölü
// bandı kırpıyoruz ki harita sayfada gereksiz boşluk bırakmasın.
const TAM_GORUNUM = {
x: 0,
y: 138,
w: HARITA_GENISLIK,
h: HARITA_YUKSEKLIK - 138,
};
type ViewBox = typeof TAM_GORUNUM;
/** "ORTA DOĞU TEKNİK ÜNİVERSİTESİ" → "Orta Doğu Teknik" */
function kisaUniAdi(ad: string): string {
const kisa = ad
.replace(/\s+(ÜNİVERSİTESİ|ENSTİTÜSÜ|MESLEK YÜKSEKOKULU)$/i, "")
.trim();
return kisa
.toLocaleLowerCase("tr-TR")
.split(" ")
.map((k) =>
k.length > 2 || k === "29"
? k.charAt(0).toLocaleUpperCase("tr-TR") + k.slice(1)
: k,
)
.join(" ");
}
/** İldeki pinlerin çoğunluk riski (eşitlikte güvenli taraf). */
function baskinRisk(pinler: HaritaPin[]): RiskSeviyesi {
const sayac: Record<RiskSeviyesi, number> = {
guvenli: 0,
"az-riskli": 0,
riskli: 0,
};
for (const p of pinler) sayac[p.risk] += p.tercihSiralari.length;
return (["guvenli", "az-riskli", "riskli"] as const).reduce((a, b) =>
sayac[b] > sayac[a] ? b : a,
);
}
/** Aynı noktaya düşen pinleri küçük bir halka üzerinde ayrıştırır. */
function cakismaAc(pinler: HaritaPin[]): HaritaPin[] {
const gruplar = new Map<string, HaritaPin[]>();
for (const p of pinler) {
const key = `${Math.round(p.x)}:${Math.round(p.y)}`;
const grup = gruplar.get(key);
if (grup) grup.push(p);
else gruplar.set(key, [p]);
}
const sonuc: HaritaPin[] = [];
for (const grup of gruplar.values()) {
if (grup.length === 1) {
sonuc.push(grup[0]);
continue;
}
grup.forEach((p, i) => {
const aci = (2 * Math.PI * i) / grup.length;
sonuc.push({ ...p, x: p.x + Math.cos(aci) * 5, y: p.y + Math.sin(aci) * 5 });
});
}
return sonuc;
}
export function TercihHaritasi({
pinler,
seciliIl,
onSeciliIlDegisti,
onTercihSec,
}: {
pinler: HaritaPin[];
/** Zoom'lanacak il (DB biçimi) — liste tarafından da kontrol edilebilir */
seciliIl: string | null;
onSeciliIlDegisti: (il: string | null) => void;
/** Alt şeritte bir tercihe dokunulunca (liste satırına in) */
onTercihSec?: (sira: number) => void;
}) {
const pathRefs = useRef(new Map<string, SVGPathElement>());
const [vb, setVb] = useState<ViewBox>(TAM_GORUNUM);
const vbRef = useRef<ViewBox>(TAM_GORUNUM);
const animRef = useRef(0);
const animateTo = useCallback((hedef: ViewBox) => {
cancelAnimationFrame(animRef.current);
const kaynak = { ...vbRef.current };
const t0 = performance.now();
const SURE = 380;
const ease = (t: number) => 1 - Math.pow(1 - t, 3);
const adim = (now: number) => {
const t = Math.min(1, (now - t0) / SURE);
const k = ease(t);
const sonraki = {
x: kaynak.x + (hedef.x - kaynak.x) * k,
y: kaynak.y + (hedef.y - kaynak.y) * k,
w: kaynak.w + (hedef.w - kaynak.w) * k,
h: kaynak.h + (hedef.h - kaynak.h) * k,
};
vbRef.current = sonraki;
setVb(sonraki);
if (t < 1) animRef.current = requestAnimationFrame(adim);
};
animRef.current = requestAnimationFrame(adim);
}, []);
useEffect(() => () => cancelAnimationFrame(animRef.current), []);
// seciliIl değişince ilgili ilin bbox'ına (veya tam görünüme) ak
useEffect(() => {
if (!seciliIl) {
animateTo(TAM_GORUNUM);
return;
}
const path = pathRefs.current.get(seciliIl);
if (!path) return;
const b = path.getBBox();
const pad = Math.max(b.width, b.height) * 0.22;
let x = b.x - pad;
let y = b.y - pad;
let w = b.width + pad * 2;
let h = b.height + pad * 2;
// viewBox oranını koru ki il yamulmadan büyüsün ve harita yüksekliği
// zoom'da değişmesin (tam görünümle aynı oran)
const oran = TAM_GORUNUM.w / TAM_GORUNUM.h;
if (w / h > oran) {
const yeniH = w / oran;
y -= (yeniH - h) / 2;
h = yeniH;
} else {
const yeniW = h * oran;
x -= (yeniW - w) / 2;
w = yeniW;
}
animateTo({ x, y, w, h });
}, [seciliIl, animateTo]);
const ilPinleri = useMemo(() => {
const gruplar = new Map<string, HaritaPin[]>();
for (const p of pinler) {
if (!p.il) continue;
const grup = gruplar.get(p.il);
if (grup) grup.push(p);
else gruplar.set(p.il, [p]);
}
return gruplar;
}, [pinler]);
// Ekran boyutu sabit kalsın diye SVG içi ölçüler zoom oranıyla çarpılır
const sf = vb.w / HARITA_GENISLIK;
const zoomda = Boolean(seciliIl);
const seciliPinler = seciliIl ? (ilPinleri.get(seciliIl) ?? []) : [];
const acikPinler = cakismaAc(seciliPinler);
return (
<div>
<div className="relative">
<svg
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
className="h-auto w-full"
role="img"
aria-label="Tercih listesinin Türkiye haritasındaki dağılımı"
>
{/* İl zeminleri */}
{cities.map((city) => {
const key = normalizeIlAdi(city.name);
const iceriyor = ilPinleri.has(key);
const secili = key === seciliIl;
return (
<path
key={city.id}
ref={(el) => {
if (el) pathRefs.current.set(key, el);
else pathRefs.current.delete(key);
}}
d={city.path}
className={
iceriyor
? "cursor-pointer transition-opacity duration-200 hover:opacity-80"
: undefined
}
fill={
secili
? "oklch(0.97 0.014 254.6)" // blue-50: seçili zemin
: iceriyor
? "oklch(0.882 0.059 254.1)" // blue-200
: "oklch(0.929 0.013 255.5)" // slate-200
}
stroke="white"
strokeWidth={secili ? 1.6 * sf : 1 * sf}
onClick={() => {
if (secili) onSeciliIlDegisti(null);
else if (iceriyor) onSeciliIlDegisti(key);
}}
>
<title>
{iceriyor
? `${city.name}: ${(ilPinleri.get(key) ?? []).reduce(
(a, p) => a + p.tercihSiralari.length,
0,
)} tercih — yakınlaşmak için tıkla`
: city.name}
</title>
</path>
);
})}
{/* Ülke seviyesi: il başına toplu pin */}
{!zoomda
? [...ilPinleri.entries()].map(([il, iller]) => {
const adet = iller.reduce(
(a, p) => a + p.tercihSiralari.length,
0,
);
const cx =
iller.reduce((a, p) => a + p.x, 0) / iller.length;
const cy =
iller.reduce((a, p) => a + p.y, 0) / iller.length;
const renk = RISK_RENK[baskinRisk(iller)];
return (
<g
key={il}
className="cursor-pointer"
onClick={() => onSeciliIlDegisti(il)}
role="button"
aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`}
>
<circle
cx={cx}
cy={cy}
r={11}
fill={renk}
stroke="white"
strokeWidth={2}
/>
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fontSize={11}
fontWeight={700}
fill="white"
className="pointer-events-none select-none"
>
{adet}
</text>
</g>
);
})
: null}
{/* İl seviyesi: üniversite pinleri */}
{zoomda
? acikPinler.map((p) => {
const r = (p.kilitli ? 7 : 8) * sf;
const etiket = p.kilitli ? null : kisaUniAdi(p.universite);
return (
<g
key={p.id}
className={onTercihSec && !p.kilitli ? "cursor-pointer" : undefined}
onClick={
onTercihSec && !p.kilitli
? () => onTercihSec(p.tercihSiralari[0])
: undefined
}
role={p.kilitli ? undefined : "button"}
aria-label={
p.kilitli
? "Kilitli tercih"
: `${etiket}: ${p.tercihSiralari.length} tercih`
}
>
<circle
cx={p.x}
cy={p.y}
r={r}
fill={p.kilitli ? "oklch(0.704 0.04 256.8)" : RISK_RENK[p.risk]}
stroke="white"
strokeWidth={2 * sf}
strokeDasharray={p.kilitli ? `${3 * sf} ${2.5 * sf}` : undefined}
/>
<text
x={p.x}
y={p.y}
textAnchor="middle"
dominantBaseline="central"
fontSize={9 * sf}
fontWeight={700}
fill="white"
className="pointer-events-none select-none"
>
{p.kilitli ? "?" : p.tercihSiralari.length}
</text>
{etiket ? (
<text
x={p.x}
y={p.y - r - 4 * sf}
textAnchor="middle"
fontSize={10.5 * sf}
fontWeight={600}
fill="oklch(0.279 0.041 260.0)" // slate-800
stroke="white"
strokeWidth={3 * sf}
paintOrder="stroke"
className="pointer-events-none select-none"
>
{etiket}
</text>
) : null}
</g>
);
})
: null}
</svg>
{zoomda ? (
<Button
size="sm"
variant="outline"
onClick={() => onSeciliIlDegisti(null)}
className="absolute left-3 top-3 cursor-pointer rounded-full bg-white/90 backdrop-blur"
>
<ArrowLeft className="size-4" aria-hidden />
Türkiye
</Button>
) : null}
</div>
{/* Seçili ilin tercih şeridi (yalnızca zoom'dayken) */}
{zoomda && seciliPinler.length > 0 ? (
<div className="mt-2">
<p className="px-1 pb-1 text-xs font-semibold text-slate-500">
{seciliIl!.toLocaleLowerCase("tr-TR")} tercihlerin
</p>
<ul className="flex gap-2 overflow-x-auto pb-1">
{seciliPinler
.flatMap((p) =>
p.tercihSiralari.map((sira) => ({ pin: p, sira })),
)
.sort((a, b) => a.sira - b.sira)
.map(({ pin, sira }) => (
<li key={sira} className="shrink-0">
<button
type="button"
onClick={onTercihSec ? () => onTercihSec(sira) : undefined}
className="flex cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-white py-1 pl-1.5 pr-3 text-xs transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
>
<span
className="flex size-5 items-center justify-center rounded-full font-heading text-[10px] font-bold text-white"
style={{ backgroundColor: RISK_RENK[pin.risk] }}
>
{sira}
</span>
{pin.kilitli ? (
<span className="inline-flex items-center gap-1 text-slate-400">
<Lock className="size-3" aria-hidden />
Kilitli tercih
</span>
) : (
<span className="max-w-40 truncate font-medium text-slate-700">
{kisaUniAdi(pin.universite)}
</span>
)}
</button>
</li>
))}
</ul>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,112 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -0,0 +1,67 @@
"use client"
import { cn } from "@/lib/utils"
import { StickToBottom } from "use-stick-to-bottom"
export type ChatContainerRootProps = {
children: React.ReactNode
className?: string
} & React.HTMLAttributes<HTMLDivElement>
export type ChatContainerContentProps = {
children: React.ReactNode
className?: string
} & React.HTMLAttributes<HTMLDivElement>
export type ChatContainerScrollAnchorProps = {
className?: string
ref?: React.RefObject<HTMLDivElement>
} & React.HTMLAttributes<HTMLDivElement>
function ChatContainerRoot({
children,
className,
...props
}: ChatContainerRootProps) {
return (
<StickToBottom
className={cn("flex overflow-y-auto", className)}
resize="smooth"
initial="instant"
role="log"
{...props}
>
{children}
</StickToBottom>
)
}
function ChatContainerContent({
children,
className,
...props
}: ChatContainerContentProps) {
return (
<StickToBottom.Content
className={cn("flex w-full flex-col", className)}
{...props}
>
{children}
</StickToBottom.Content>
)
}
function ChatContainerScrollAnchor({
className,
...props
}: ChatContainerScrollAnchorProps) {
return (
<div
className={cn("h-px w-full shrink-0 scroll-mt-4", className)}
aria-hidden="true"
{...props}
/>
)
}
export { ChatContainerRoot, ChatContainerContent, ChatContainerScrollAnchor }

View File

@@ -0,0 +1,94 @@
"use client"
import { cn } from "@/lib/utils"
import React, { useEffect, useState } from "react"
import { codeToHtml } from "shiki"
export type CodeBlockProps = {
children?: React.ReactNode
className?: string
} & React.HTMLProps<HTMLDivElement>
function CodeBlock({ children, className, ...props }: CodeBlockProps) {
return (
<div
className={cn(
"not-prose flex w-full flex-col overflow-clip border",
"border-border bg-card text-card-foreground rounded-xl",
className
)}
{...props}
>
{children}
</div>
)
}
export type CodeBlockCodeProps = {
code: string
language?: string
theme?: string
className?: string
} & React.HTMLProps<HTMLDivElement>
function CodeBlockCode({
code,
language = "tsx",
theme = "github-light",
className,
...props
}: CodeBlockCodeProps) {
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null)
useEffect(() => {
async function highlight() {
if (!code) {
setHighlightedHtml("<pre><code></code></pre>")
return
}
const html = await codeToHtml(code, { lang: language, theme })
setHighlightedHtml(html)
}
highlight()
}, [code, language, theme])
const classNames = cn(
"w-full overflow-x-auto text-[13px] [&>pre]:px-4 [&>pre]:py-4",
className
)
// SSR fallback: render plain code if not hydrated yet
return highlightedHtml ? (
<div
className={classNames}
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
{...props}
/>
) : (
<div className={classNames} {...props}>
<pre>
<code>{code}</code>
</pre>
</div>
)
}
export type CodeBlockGroupProps = React.HTMLAttributes<HTMLDivElement>
function CodeBlockGroup({
children,
className,
...props
}: CodeBlockGroupProps) {
return (
<div
className={cn("flex items-center justify-between", className)}
{...props}
>
{children}
</div>
)
}
export { CodeBlockGroup, CodeBlockCode, CodeBlock }

View File

@@ -0,0 +1,499 @@
"use client"
import { cn } from "@/lib/utils"
import React from "react"
export interface LoaderProps {
variant?:
| "circular"
| "classic"
| "pulse"
| "pulse-dot"
| "dots"
| "typing"
| "wave"
| "bars"
| "terminal"
| "text-blink"
| "text-shimmer"
| "loading-dots"
size?: "sm" | "md" | "lg"
text?: string
className?: string
}
export function CircularLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const sizeClasses = {
sm: "size-4",
md: "size-5",
lg: "size-6",
}
return (
<div
className={cn(
"border-primary animate-spin rounded-full border-2 border-t-transparent",
sizeClasses[size],
className
)}
>
<span className="sr-only">Loading</span>
</div>
)
}
export function ClassicLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const sizeClasses = {
sm: "size-4",
md: "size-5",
lg: "size-6",
}
const barSizes = {
sm: { height: "6px", width: "1.5px" },
md: { height: "8px", width: "2px" },
lg: { height: "10px", width: "2.5px" },
}
return (
<div className={cn("relative", sizeClasses[size], className)}>
<div className="absolute h-full w-full">
{[...Array(12)].map((_, i) => (
<div
key={i}
className="bg-primary absolute animate-[spinner-fade_1.2s_linear_infinite] rounded-full"
style={{
top: "0",
left: "50%",
marginLeft:
size === "sm" ? "-0.75px" : size === "lg" ? "-1.25px" : "-1px",
transformOrigin: `${size === "sm" ? "0.75px" : size === "lg" ? "1.25px" : "1px"} ${size === "sm" ? "10px" : size === "lg" ? "14px" : "12px"}`,
transform: `rotate(${i * 30}deg)`,
opacity: 0,
animationDelay: `${i * 0.1}s`,
height: barSizes[size].height,
width: barSizes[size].width,
}}
/>
))}
</div>
<span className="sr-only">Loading</span>
</div>
)
}
export function PulseLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const sizeClasses = {
sm: "size-4",
md: "size-5",
lg: "size-6",
}
return (
<div className={cn("relative", sizeClasses[size], className)}>
<div className="border-primary absolute inset-0 animate-[thin-pulse_1.5s_ease-in-out_infinite] rounded-full border-2" />
<span className="sr-only">Loading</span>
</div>
)
}
export function PulseDotLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const sizeClasses = {
sm: "size-1",
md: "size-2",
lg: "size-3",
}
return (
<div
className={cn(
"bg-primary animate-[pulse-dot_1.2s_ease-in-out_infinite] rounded-full",
sizeClasses[size],
className
)}
>
<span className="sr-only">Loading</span>
</div>
)
}
export function DotsLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const dotSizes = {
sm: "h-1.5 w-1.5",
md: "h-2 w-2",
lg: "h-2.5 w-2.5",
}
const containerSizes = {
sm: "h-4",
md: "h-5",
lg: "h-6",
}
return (
<div
className={cn(
"flex items-center space-x-1",
containerSizes[size],
className
)}
>
{[...Array(3)].map((_, i) => (
<div
key={i}
className={cn(
"bg-primary animate-[bounce-dots_1.4s_ease-in-out_infinite] rounded-full",
dotSizes[size]
)}
style={{
animationDelay: `${i * 160}ms`,
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function TypingLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const dotSizes = {
sm: "h-1 w-1",
md: "h-1.5 w-1.5",
lg: "h-2 w-2",
}
const containerSizes = {
sm: "h-4",
md: "h-5",
lg: "h-6",
}
return (
<div
className={cn(
"flex items-center space-x-1",
containerSizes[size],
className
)}
>
{[...Array(3)].map((_, i) => (
<div
key={i}
className={cn(
"bg-primary animate-[typing_1s_infinite] rounded-full",
dotSizes[size]
)}
style={{
animationDelay: `${i * 250}ms`,
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function WaveLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const barWidths = {
sm: "w-0.5",
md: "w-0.5",
lg: "w-1",
}
const containerSizes = {
sm: "h-4",
md: "h-5",
lg: "h-6",
}
const heights = {
sm: ["6px", "9px", "12px", "9px", "6px"],
md: ["8px", "12px", "16px", "12px", "8px"],
lg: ["10px", "15px", "20px", "15px", "10px"],
}
return (
<div
className={cn(
"flex items-center gap-0.5",
containerSizes[size],
className
)}
>
{[...Array(5)].map((_, i) => (
<div
key={i}
className={cn(
"bg-primary animate-[wave_1s_ease-in-out_infinite] rounded-full",
barWidths[size]
)}
style={{
animationDelay: `${i * 100}ms`,
height: heights[size][i],
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function BarsLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const barWidths = {
sm: "w-1",
md: "w-1.5",
lg: "w-2",
}
const containerSizes = {
sm: "h-4 gap-1",
md: "h-5 gap-1.5",
lg: "h-6 gap-2",
}
return (
<div className={cn("flex", containerSizes[size], className)}>
{[...Array(3)].map((_, i) => (
<div
key={i}
className={cn(
"bg-primary h-full animate-[wave-bars_1.2s_ease-in-out_infinite]",
barWidths[size]
)}
style={{
animationDelay: `${i * 0.2}s`,
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function TerminalLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const cursorSizes = {
sm: "h-3 w-1.5",
md: "h-4 w-2",
lg: "h-5 w-2.5",
}
const textSizes = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
}
const containerSizes = {
sm: "h-4",
md: "h-5",
lg: "h-6",
}
return (
<div
className={cn(
"flex items-center space-x-1",
containerSizes[size],
className
)}
>
<span className={cn("text-primary font-mono", textSizes[size])}>
{">"}
</span>
<div
className={cn(
"bg-primary animate-[blink_1s_step-end_infinite]",
cursorSizes[size]
)}
/>
<span className="sr-only">Loading</span>
</div>
)
}
export function TextBlinkLoader({
text = "Thinking",
className,
size = "md",
}: {
text?: string
className?: string
size?: "sm" | "md" | "lg"
}) {
const textSizes = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
}
return (
<div
className={cn(
"animate-[text-blink_2s_ease-in-out_infinite] font-medium",
textSizes[size],
className
)}
>
{text}
</div>
)
}
export function TextShimmerLoader({
text = "Thinking",
className,
size = "md",
}: {
text?: string
className?: string
size?: "sm" | "md" | "lg"
}) {
const textSizes = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
}
return (
<div
className={cn(
"bg-[linear-gradient(to_right,var(--muted-foreground)_40%,var(--foreground)_60%,var(--muted-foreground)_80%)]",
"bg-size-[200%_auto] bg-clip-text font-medium text-transparent",
"animate-[shimmer_4s_infinite_linear]",
textSizes[size],
className
)}
>
{text}
</div>
)
}
export function TextDotsLoader({
className,
text = "Thinking",
size = "md",
}: {
className?: string
text?: string
size?: "sm" | "md" | "lg"
}) {
const textSizes = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
}
return (
<div
className={cn("inline-flex items-center", className)}
>
<span className={cn("text-primary font-medium", textSizes[size])}>
{text}
</span>
<span className="inline-flex">
<span className="text-primary animate-[loading-dots_1.4s_infinite_0.2s]">
.
</span>
<span className="text-primary animate-[loading-dots_1.4s_infinite_0.4s]">
.
</span>
<span className="text-primary animate-[loading-dots_1.4s_infinite_0.6s]">
.
</span>
</span>
</div>
)
}
function Loader({
variant = "circular",
size = "md",
text,
className,
}: LoaderProps) {
switch (variant) {
case "circular":
return <CircularLoader size={size} className={className} />
case "classic":
return <ClassicLoader size={size} className={className} />
case "pulse":
return <PulseLoader size={size} className={className} />
case "pulse-dot":
return <PulseDotLoader size={size} className={className} />
case "dots":
return <DotsLoader size={size} className={className} />
case "typing":
return <TypingLoader size={size} className={className} />
case "wave":
return <WaveLoader size={size} className={className} />
case "bars":
return <BarsLoader size={size} className={className} />
case "terminal":
return <TerminalLoader size={size} className={className} />
case "text-blink":
return <TextBlinkLoader text={text} size={size} className={className} />
case "text-shimmer":
return <TextShimmerLoader text={text} size={size} className={className} />
case "loading-dots":
return <TextDotsLoader text={text} size={size} className={className} />
default:
return <CircularLoader size={size} className={className} />
}
}
export { Loader }

View File

@@ -0,0 +1,110 @@
import { cn } from "@/lib/utils"
import { marked } from "marked"
import { memo, useId, useMemo } from "react"
import ReactMarkdown, { Components } from "react-markdown"
import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import { CodeBlock, CodeBlockCode } from "./code-block"
export type MarkdownProps = {
children: string
id?: string
className?: string
components?: Partial<Components>
}
function parseMarkdownIntoBlocks(markdown: string): string[] {
const tokens = marked.lexer(markdown)
return tokens.map((token) => token.raw)
}
function extractLanguage(className?: string): string {
if (!className) return "plaintext"
const match = className.match(/language-(\w+)/)
return match ? match[1] : "plaintext"
}
const INITIAL_COMPONENTS: Partial<Components> = {
code: function CodeComponent({ className, children, ...props }) {
const isInline =
!props.node?.position?.start.line ||
props.node?.position?.start.line === props.node?.position?.end.line
if (isInline) {
return (
<span
className={cn(
"bg-primary-foreground rounded-sm px-1 font-mono text-sm",
className
)}
{...props}
>
{children}
</span>
)
}
const language = extractLanguage(className)
return (
<CodeBlock className={className}>
<CodeBlockCode code={children as string} language={language} />
</CodeBlock>
)
},
pre: function PreComponent({ children }) {
return <>{children}</>
},
}
const MemoizedMarkdownBlock = memo(
function MarkdownBlock({
content,
components = INITIAL_COMPONENTS,
}: {
content: string
components?: Partial<Components>
}) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={components}
>
{content}
</ReactMarkdown>
)
},
function propsAreEqual(prevProps, nextProps) {
return prevProps.content === nextProps.content
}
)
MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock"
function MarkdownComponent({
children,
id,
className,
components = INITIAL_COMPONENTS,
}: MarkdownProps) {
const generatedId = useId()
const blockId = id ?? generatedId
const blocks = useMemo(() => parseMarkdownIntoBlocks(children), [children])
return (
<div className={className}>
{blocks.map((block, index) => (
<MemoizedMarkdownBlock
key={`${blockId}-block-${index}`}
content={block}
components={components}
/>
))}
</div>
)
}
const Markdown = memo(MarkdownComponent)
Markdown.displayName = "Markdown"
export { Markdown }

View File

@@ -0,0 +1,120 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { Markdown } from "./markdown"
export type MessageProps = {
children: React.ReactNode
className?: string
} & React.HTMLProps<HTMLDivElement>
const Message = ({ children, className, ...props }: MessageProps) => (
<div className={cn("flex gap-3", className)} {...props}>
{children}
</div>
)
export type MessageAvatarProps = {
src: string
alt: string
fallback?: string
delayMs?: number
className?: string
}
const MessageAvatar = ({
src,
alt,
fallback,
delayMs,
className,
}: MessageAvatarProps) => {
return (
<Avatar className={cn("h-8 w-8 shrink-0", className)}>
<AvatarImage src={src} alt={alt} />
{fallback && (
<AvatarFallback delayMs={delayMs}>{fallback}</AvatarFallback>
)}
</Avatar>
)
}
export type MessageContentProps = {
children: React.ReactNode
markdown?: boolean
className?: string
} & React.ComponentProps<typeof Markdown> &
React.HTMLProps<HTMLDivElement>
const MessageContent = ({
children,
markdown = false,
className,
...props
}: MessageContentProps) => {
const classNames = cn(
"rounded-lg p-2 text-foreground bg-secondary prose break-words whitespace-normal",
className
)
return markdown ? (
<Markdown className={classNames} {...props}>
{children as string}
</Markdown>
) : (
<div className={classNames} {...props}>
{children}
</div>
)
}
export type MessageActionsProps = {
children: React.ReactNode
className?: string
} & React.HTMLProps<HTMLDivElement>
const MessageActions = ({
children,
className,
...props
}: MessageActionsProps) => (
<div
className={cn("text-muted-foreground flex items-center gap-2", className)}
{...props}
>
{children}
</div>
)
export type MessageActionProps = {
className?: string
tooltip: React.ReactNode
children: React.ReactNode
side?: "top" | "bottom" | "left" | "right"
} & React.ComponentProps<typeof Tooltip>
const MessageAction = ({
tooltip,
children,
className,
side = "top",
...props
}: MessageActionProps) => {
return (
<TooltipProvider>
<Tooltip {...props}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side} className={className}>
{tooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
export { Message, MessageAvatar, MessageContent, MessageActions, MessageAction }

View File

@@ -0,0 +1,233 @@
"use client"
import { Textarea } from "@/components/ui/textarea"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import React, {
createContext,
useContext,
useLayoutEffect,
useRef,
useState,
} from "react"
type PromptInputContextType = {
isLoading: boolean
value: string
setValue: (value: string) => void
maxHeight: number | string
onSubmit?: () => void
disabled?: boolean
textareaRef: React.RefObject<HTMLTextAreaElement | null>
}
const PromptInputContext = createContext<PromptInputContextType>({
isLoading: false,
value: "",
setValue: () => {},
maxHeight: 240,
onSubmit: undefined,
disabled: false,
textareaRef: React.createRef<HTMLTextAreaElement>(),
})
function usePromptInput() {
return useContext(PromptInputContext)
}
export type PromptInputProps = {
isLoading?: boolean
value?: string
onValueChange?: (value: string) => void
maxHeight?: number | string
onSubmit?: () => void
children: React.ReactNode
className?: string
disabled?: boolean
} & React.ComponentProps<"div">
function PromptInput({
className,
isLoading = false,
maxHeight = 240,
value,
onValueChange,
onSubmit,
children,
disabled = false,
onClick,
...props
}: PromptInputProps) {
const [internalValue, setInternalValue] = useState(value || "")
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleChange = (newValue: string) => {
setInternalValue(newValue)
onValueChange?.(newValue)
}
const handleClick: React.MouseEventHandler<HTMLDivElement> = (e) => {
if (!disabled) textareaRef.current?.focus()
onClick?.(e)
}
return (
<TooltipProvider>
<PromptInputContext.Provider
value={{
isLoading,
value: value ?? internalValue,
setValue: onValueChange ?? handleChange,
maxHeight,
onSubmit,
disabled,
textareaRef,
}}
>
<div
onClick={handleClick}
className={cn(
"border-input bg-background cursor-text rounded-3xl border p-2 shadow-xs",
disabled && "cursor-not-allowed opacity-60",
className
)}
{...props}
>
{children}
</div>
</PromptInputContext.Provider>
</TooltipProvider>
)
}
export type PromptInputTextareaProps = {
disableAutosize?: boolean
} & React.ComponentProps<typeof Textarea>
function PromptInputTextarea({
className,
onKeyDown,
disableAutosize = false,
...props
}: PromptInputTextareaProps) {
const { value, setValue, maxHeight, onSubmit, disabled, textareaRef } =
usePromptInput()
const adjustHeight = (el: HTMLTextAreaElement | null) => {
if (!el || disableAutosize) return
el.style.height = "auto"
if (typeof maxHeight === "number") {
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`
} else {
el.style.height = `min(${el.scrollHeight}px, ${maxHeight})`
}
}
const handleRef = (el: HTMLTextAreaElement | null) => {
textareaRef.current = el
adjustHeight(el)
}
useLayoutEffect(() => {
if (!textareaRef.current || disableAutosize) return
const el = textareaRef.current
el.style.height = "auto"
if (typeof maxHeight === "number") {
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`
} else {
el.style.height = `min(${el.scrollHeight}px, ${maxHeight})`
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, maxHeight, disableAutosize])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
adjustHeight(e.target)
setValue(e.target.value)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
onSubmit?.()
}
onKeyDown?.(e)
}
return (
<Textarea
ref={handleRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
className={cn(
"text-primary min-h-[44px] w-full resize-none border-none bg-transparent shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
className
)}
rows={1}
disabled={disabled}
{...props}
/>
)
}
export type PromptInputActionsProps = React.HTMLAttributes<HTMLDivElement>
function PromptInputActions({
children,
className,
...props
}: PromptInputActionsProps) {
return (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
)
}
export type PromptInputActionProps = {
className?: string
tooltip: React.ReactNode
children: React.ReactNode
side?: "top" | "bottom" | "left" | "right"
} & React.ComponentProps<typeof Tooltip>
function PromptInputAction({
tooltip,
children,
className,
side = "top",
...props
}: PromptInputActionProps) {
const { disabled } = usePromptInput()
return (
<Tooltip {...props}>
<TooltipTrigger
asChild
disabled={disabled}
onClick={(event) => event.stopPropagation()}
>
{children}
</TooltipTrigger>
<TooltipContent side={side} className={className}>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
export {
PromptInput,
PromptInputTextarea,
PromptInputActions,
PromptInputAction,
}

View File

@@ -0,0 +1,117 @@
"use client"
import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { VariantProps } from "class-variance-authority"
export type PromptSuggestionProps = {
children: React.ReactNode
variant?: VariantProps<typeof buttonVariants>["variant"]
size?: VariantProps<typeof buttonVariants>["size"]
className?: string
highlight?: string
} & React.ButtonHTMLAttributes<HTMLButtonElement>
function PromptSuggestion({
children,
variant,
size,
className,
highlight,
...props
}: PromptSuggestionProps) {
const isHighlightMode = highlight !== undefined && highlight.trim() !== ""
const content = typeof children === "string" ? children : ""
if (!isHighlightMode) {
return (
<Button
variant={variant || "outline"}
size={size || "lg"}
className={cn("rounded-full", className)}
{...props}
>
{children}
</Button>
)
}
if (!content) {
return (
<Button
variant={variant || "ghost"}
size={size || "sm"}
className={cn(
"w-full cursor-pointer justify-start rounded-xl py-2",
"hover:bg-accent",
className
)}
{...props}
>
{children}
</Button>
)
}
const trimmedHighlight = highlight.trim()
const contentLower = content.toLowerCase()
const highlightLower = trimmedHighlight.toLowerCase()
const shouldHighlight = contentLower.includes(highlightLower)
return (
<Button
variant={variant || "ghost"}
size={size || "sm"}
className={cn(
"w-full cursor-pointer justify-start gap-0 rounded-xl py-2",
"hover:bg-accent",
className
)}
{...props}
>
{shouldHighlight ? (
(() => {
const index = contentLower.indexOf(highlightLower)
if (index === -1)
return (
<span className="text-muted-foreground whitespace-pre-wrap">
{content}
</span>
)
const actualHighlightedText = content.substring(
index,
index + highlightLower.length
)
const before = content.substring(0, index)
const after = content.substring(index + actualHighlightedText.length)
return (
<>
{before && (
<span className="text-muted-foreground whitespace-pre-wrap">
{before}
</span>
)}
<span className="text-primary font-medium whitespace-pre-wrap">
{actualHighlightedText}
</span>
{after && (
<span className="text-muted-foreground whitespace-pre-wrap">
{after}
</span>
)}
</>
)
})()
) : (
<span className="text-muted-foreground whitespace-pre-wrap">
{content}
</span>
)}
</Button>
)
}
export { PromptSuggestion }

View File

@@ -0,0 +1,42 @@
"use client"
import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { type VariantProps } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { useStickToBottomContext } from "use-stick-to-bottom"
export type ScrollButtonProps = {
className?: string
variant?: VariantProps<typeof buttonVariants>["variant"]
size?: VariantProps<typeof buttonVariants>["size"]
} & React.ButtonHTMLAttributes<HTMLButtonElement>
function ScrollButton({
className,
variant = "outline",
size = "sm",
...props
}: ScrollButtonProps) {
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
return (
<Button
variant={variant}
size={size}
className={cn(
"h-10 w-10 rounded-full transition-all duration-150 ease-out",
!isAtBottom
? "translate-y-0 scale-100 opacity-100"
: "pointer-events-none translate-y-4 scale-95 opacity-0",
className
)}
onClick={() => scrollToBottom()}
{...props}
>
<ChevronDown className="h-5 w-5" />
</Button>
)
}
export { ScrollButton }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View File

@@ -0,0 +1,236 @@
"use client"
import {
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
type RefAttributes,
type RefObject,
} from "react"
import {
motion,
useInView,
type DOMMotionComponents,
type HTMLMotionProps,
type MotionProps,
} from "motion/react"
import { cn } from "@/lib/utils"
const motionElements = {
article: motion.article,
div: motion.div,
h1: motion.h1,
h2: motion.h2,
h3: motion.h3,
h4: motion.h4,
h5: motion.h5,
h6: motion.h6,
li: motion.li,
p: motion.p,
section: motion.section,
span: motion.span,
} as const
type MotionElementType = Extract<
keyof DOMMotionComponents,
keyof typeof motionElements
>
type TypingAnimationMotionComponent = ComponentType<
Omit<HTMLMotionProps<"span">, "ref"> & RefAttributes<HTMLElement>
>
interface TypingAnimationProps extends Omit<MotionProps, "children"> {
children?: string
words?: string[]
className?: string
duration?: number
typeSpeed?: number
deleteSpeed?: number
delay?: number
pauseDelay?: number
loop?: boolean
as?: MotionElementType
startOnView?: boolean
showCursor?: boolean
blinkCursor?: boolean
cursorStyle?: "line" | "block" | "underscore"
}
export function TypingAnimation({
children,
words,
className,
duration = 100,
typeSpeed,
deleteSpeed,
delay = 0,
pauseDelay = 1000,
loop = false,
as: Component = "span",
startOnView = true,
showCursor = true,
blinkCursor = true,
cursorStyle = "line",
...props
}: TypingAnimationProps) {
const MotionComponent = motionElements[
Component
] as TypingAnimationMotionComponent
const [displayedText, setDisplayedText] = useState<string>("")
const [currentWordIndex, setCurrentWordIndex] = useState(0)
const [currentCharIndex, setCurrentCharIndex] = useState(0)
const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing")
const elementRef = useRef<HTMLElement | null>(null)
const isInView = useInView(elementRef as RefObject<Element>, {
amount: 0.3,
once: true,
})
const wordsToAnimate = useMemo(
() => words ?? (children ? [children] : []),
[words, children]
)
const hasMultipleWords = wordsToAnimate.length > 1
const typingSpeed = typeSpeed ?? duration
const deletingSpeed = deleteSpeed ?? typingSpeed / 2
const shouldStart = startOnView ? isInView : true
const animationSourceKey = useMemo(
() => (words ? words.join("\u0000") : (children ?? "")),
[words, children]
)
useEffect(() => {
setDisplayedText("")
setCurrentWordIndex(0)
setCurrentCharIndex(0)
setPhase("typing")
}, [animationSourceKey])
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
if (shouldStart && wordsToAnimate.length > 0) {
const timeoutDelay =
delay > 0 && displayedText === ""
? delay
: phase === "typing"
? typingSpeed
: phase === "deleting"
? deletingSpeed
: pauseDelay
timeout = setTimeout(() => {
const currentWord = wordsToAnimate[currentWordIndex] || ""
const graphemes = Array.from(currentWord)
switch (phase) {
case "typing":
if (currentCharIndex < graphemes.length) {
setDisplayedText(
graphemes.slice(0, currentCharIndex + 1).join("")
)
setCurrentCharIndex(currentCharIndex + 1)
} else {
if (hasMultipleWords || loop) {
const isLastWord =
currentWordIndex === wordsToAnimate.length - 1
if (!isLastWord || loop) {
setPhase("pause")
}
}
}
break
case "pause":
setPhase("deleting")
break
case "deleting":
if (currentCharIndex > 0) {
setDisplayedText(
graphemes.slice(0, currentCharIndex - 1).join("")
)
setCurrentCharIndex(currentCharIndex - 1)
} else {
const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length
setCurrentWordIndex(nextIndex)
setPhase("typing")
}
break
}
}, timeoutDelay)
}
return () => {
if (timeout !== null) {
clearTimeout(timeout)
}
}
}, [
shouldStart,
phase,
currentCharIndex,
currentWordIndex,
displayedText,
wordsToAnimate,
hasMultipleWords,
loop,
typingSpeed,
deletingSpeed,
pauseDelay,
delay,
])
const currentWordGraphemes = Array.from(
wordsToAnimate[currentWordIndex] || ""
)
const isComplete =
!loop &&
currentWordIndex === wordsToAnimate.length - 1 &&
currentCharIndex >= currentWordGraphemes.length &&
phase !== "deleting"
const shouldShowCursor =
showCursor &&
!isComplete &&
(hasMultipleWords || loop || currentCharIndex < currentWordGraphemes.length)
const getCursorChar = () => {
switch (cursorStyle) {
case "block":
return "▌"
case "underscore":
return "_"
case "line":
default:
return "|"
}
}
return (
<MotionComponent
ref={elementRef}
className={cn(
"leading-20 tracking-[-0.02em]",
Component === "span" && "inline-block",
className
)}
{...props}
>
{displayedText}
{shouldShowCursor && (
<span
className={cn("inline-block", blinkCursor && "animate-blink-cursor")}
>
{getCursorChar()}
</span>
)}
</MotionComponent>
)
}

View File

@@ -2,24 +2,30 @@ import Link from "next/link";
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 { SignOutButton } from "./sign-out-button";
export async function UserNav() {
const u = await getCurrentUser();
// Manuel liste localStorage'ta yaşadığı için Listem girişsiz de çalışır
if (!u) {
return (
<Button
asChild
className="h-12 cursor-pointer rounded-full bg-slate-900 px-7 text-base font-medium text-white transition-colors duration-200 hover:bg-slate-700"
>
<Link href="/giris">Giriş yap</Link>
</Button>
<div className="flex items-center gap-2">
<ListemButonu />
<Button
asChild
className="h-12 cursor-pointer rounded-full bg-slate-900 px-7 text-base font-medium text-white transition-colors duration-200 hover:bg-slate-700"
>
<Link href="/giris">Giriş yap</Link>
</Button>
</div>
);
}
return (
<div className="flex items-center gap-2">
<ListemButonu />
<Link
href="/paket"
className="inline-flex h-12 items-center gap-2 whitespace-nowrap rounded-full border border-slate-200 bg-white px-5 text-sm font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"

View File

@@ -23,11 +23,15 @@ const TercihSchema = z.object({
const RaporSchema = z.object({
tercihler: z.array(TercihSchema),
genelDegerlendirme: z.string(),
uyarilar: z.array(z.string()).min(2).max(3),
});
export type RaporSonuc = z.infer<typeof RaporSchema> & {
// uyarilar şemada zorunlu ama eski kayıtlı raporlarda yok → tipte opsiyonel
export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
uyarilar?: string[];
// Render için havuzdan zenginleştirilmiş program bilgisi
// efektifSira: COALESCE(sira2025, sira2024) — eski kayıtlı raporlarda yok
// siraGecmisi: [2021..2025] taban sıralamaları — deterministik trend oku
programlar: Record<
string,
{
@@ -36,6 +40,7 @@ export type RaporSonuc = z.infer<typeof RaporSchema> & {
il: string | null;
unitur: string | null;
efektifSira?: number | null;
siraGecmisi?: (number | null)[];
}
>;
};
@@ -50,17 +55,104 @@ KURALLAR (kesin):
- Liste yapısı: en üstte 4-6 hayal, ortada 12-14 dengeli, sonda 5-7 garanti.
Tercih sırası MUTLAKA iyi taban sıralamasından kötüye doğru gitmeli
(küçük taban sıralaması = daha zor bölüm üstte).
- gerekce: bu programın bu aday için neden mantıklı olduğu (1-2 cümle, Türkçe).
- riskNotu: yerleşme ihtimali değerlendirmesi. ASLA "garanti", "kesin",
"yüzde yüz" deme; "yüksek/orta/düşük ihtimal" dili kullan.
- trendOzeti: son 4 yılın taban sıralaması hareketi (ör. "2022'den beri taban
sıralaması 45B'den 38B'ye indi, bölüm zorlaşıyor").
- METİN DİYETİ: aday 24 satır okuyacak; her alan TEK kısa cümle, dolgu söz yok.
- gerekce: bu programın bu aday için neden mantıklı olduğu (TEK cümle,
en fazla ~15 kelime, Türkçe).
- riskNotu: yerleşme ihtimali değerlendirmesi (TEK kısa cümle). ASLA "garanti",
"kesin", "yüzde yüz" deme; "yüksek/orta/düşük ihtimal" dili kullan.
- trendOzeti: son 4 yılın taban sıralaması hareketi, TEK cümle (ör. "2022'den
beri taban 45B'den 38B'ye indi, bölüm zorlaşıyor").
- Kontenjan artışı/azalışı ve yerleşen sayısını değerlendirmene kat.
- Adayın notlarındaki şehir/bölüm tercihlerine saygı göster ama dengeyi bozma.
- genelDegerlendirme: listenin genel stratejisi + adaya 2-3 pratik uyarı (Türkçe).`;
- genelDegerlendirme: listenin genel stratejisi, EN FAZLA 2 cümle. Uyarıları
buraya yazma.
- uyarilar: adaya 2-3 pratik, eyleme dönük uyarı; her biri tek kısa cümle.`;
export class RaporUretimHatasi extends Error {}
/** Havuz programını render için gereken bilgiye indirger. */
function programBilgisi(p: AdayProgram): RaporSonuc["programlar"][string] {
const gecmis = [p.sira2021, p.sira2022, p.sira2023, p.sira2024, p.sira2025];
return {
isim: p.isim,
universite: p.universite,
il: p.il,
unitur: p.unitur,
efektifSira: p.sira2025 ?? p.sira2024,
siraGecmisi: gecmis.some((s) => s != null) ? gecmis : undefined,
};
}
// --- Dev mock: API key yokken (yalnızca development) gerçek havuzdan sahte
// ama tutarlı bir rapor üret. Akış (kredi, blur, risk renkleri, revizyon)
// gerçek AI olmadan uçtan uca test edilebilsin diye. Prod'da asla çalışmaz.
function mockAktif(): boolean {
return (
process.env.NODE_ENV !== "production" &&
!process.env.ANTHROPIC_API_KEY &&
!process.env.ANTHROPIC_AUTH_TOKEN
);
}
function mockRapor(
havuz: AdayProgram[],
params: RaporParams,
revizyonNotu?: string,
): RaporSonuc {
const efektif = (p: AdayProgram) => p.sira2025 ?? p.sira2024 ?? 0;
const dilimden = (dilim: AdayProgram["dilim"], adet: number) =>
havuz
.filter((p) => p.dilim === dilim)
.sort((a, b) => efektif(a) - efektif(b))
.slice(0, adet);
// 5 hayal + 13 dengeli + 6 garanti; eksik dilim varsa havuzdan tamamla
const secilen = [
...dilimden("hayal", 5),
...dilimden("dengeli", 13),
...dilimden("garanti", 6),
];
for (const p of havuz) {
if (secilen.length >= 24) break;
if (!secilen.includes(p)) secilen.push(p);
}
const tercihler = secilen.slice(0, 24).map((p, i) => ({
programId: p.id,
sira: i + 1,
dilim: p.dilim,
gerekce: `(Dev mock) ${p.isim}, seçtiğin ilgi alanlarıyla ve ${params.sira.toLocaleString("tr-TR")}. sıralamanla uyumlu bir aday.`,
riskNotu:
p.dilim === "hayal"
? "(Dev mock) Taban sıralaması senin üstünde kapandı; düşük ihtimal ama listenin başında yeri var."
: p.dilim === "dengeli"
? "(Dev mock) Taban sıralaması senin civarında; orta-yüksek ihtimalle yerleşilebilir."
: "(Dev mock) Taban sıralaması senin belirgin altında; yüksek ihtimalle yerleşirsin.",
trendOzeti:
"(Dev mock) Son 4 yılda taban sıralaması görece stabil seyretti.",
}));
const programlar: RaporSonuc["programlar"] = {};
for (const p of secilen.slice(0, 24)) {
programlar[p.id] = programBilgisi(p);
}
return {
tercihler,
genelDegerlendirme: `(Dev mock — ANTHROPIC_API_KEY tanımlı değil, bu rapor gerçek AI çıktısı değildir.) ${params.sira.toLocaleString(
"tr-TR",
)}. sıra için hayal/dengeli/garanti dağılımlı 24 tercihlik örnek liste kuruldu.${
revizyonNotu ? ` Revizyon isteğin not alındı: "${revizyonNotu}".` : ""
}`,
uyarilar: [
"(Dev mock) Tercih dönemi kapanmadan listeyi ÖSYM sistemine kendin gir.",
"(Dev mock) Garanti dilimindeki son 3 tercihi asla silme.",
],
programlar,
};
}
export async function raporUret(
params: RaporParams,
revizyonNotu?: string,
@@ -91,6 +183,12 @@ export async function raporUret(
);
}
// Dev'de API key yoksa gerçek AI yerine mock rapor (üretim gecikmesi taklidi)
if (mockAktif()) {
await new Promise((r) => setTimeout(r, 1500));
return mockRapor(havuz, params, revizyonNotu);
}
const client = getAnthropic();
const havuzJson = havuzuKompaktJson(havuz);
const kullaniciMesaji = [
@@ -165,13 +263,7 @@ export async function raporUret(
const programlar: RaporSonuc["programlar"] = {};
for (const p of havuz) {
programlar[p.id] = {
isim: p.isim,
universite: p.universite,
il: p.il,
unitur: p.unitur,
efektifSira: p.sira2025 ?? p.sira2024,
};
programlar[p.id] = programBilgisi(p);
}
return { ...sonuc, programlar };
}

59
src/lib/harita.ts Normal file
View File

@@ -0,0 +1,59 @@
// turkey-map-react SVG uzayı (viewBox 0 0 1050 585) ile coğrafi koordinatlar
// arasındaki dönüşüm ve üniversite konum çözümü.
//
// Afin katsayılar, 81 il path'inin bbox merkezleri ile il merkezlerinin
// (lat, lng) değerleri arasında en küçük kareler uyumuyla hesaplanıp
// sabitlendi; ölçek doğrulaması: ~0.6 px/km her iki eksende de tutarlı.
import { UNI_KOORDINAT, IL_MERKEZ, type LatLng } from "./uni-koordinatlar";
export const HARITA_GENISLIK = 1050;
export const HARITA_YUKSEKLIK = 585;
export function latLngToXy([lat, lng]: LatLng): { x: number; y: number } {
return {
x: 52.5215 * lng - 1332.1743,
y: -65.4185 * lat + 2917.4061,
};
}
/** Harita il adını DB formatına çevirir: "Hakkâri" → "HAKKARİ". */
export function normalizeIlAdi(name: string): string {
return name
.toLocaleUpperCase("tr-TR")
.replace(/Â/g, "A")
.replace(/Î/g, "İ")
.replace(/Û/g, "U");
}
/** DB'deki üniversite adının "(İL)" ekini ayıklar. */
export function uniAdiNormalize(ad: string): string {
return ad.replace(/\s*\([^)]*\)\s*$/, "").trim();
}
export type UniKonum = {
x: number;
y: number;
/** true: kampüs koordinatı; false: il merkezine düşen yaklaşık konum */
kesin: boolean;
};
/**
* Üniversitenin harita üzerindeki konumu. Sıra: "AD|İL" → "AD" → il merkezi.
* KKTC ve yurtdışı kampüsler için null (harita dışı).
*/
export function uniKonum(universite: string, il: string | null): UniKonum | null {
const ad = uniAdiNormalize(universite);
const koord =
(il ? UNI_KOORDINAT[`${ad}|${il}`] : undefined) ?? UNI_KOORDINAT[ad];
if (koord) return { ...latLngToXy(koord), kesin: true };
const merkez = il ? IL_MERKEZ[il] : undefined;
if (merkez) return { ...latLngToXy(merkez), kesin: false };
return null;
}
/** İl merkezinin harita konumu (kilitli/adsız pinler için). */
export function ilKonum(il: string | null): { x: number; y: number } | null {
const merkez = il ? IL_MERKEZ[il] : undefined;
return merkez ? latLngToXy(merkez) : null;
}

73
src/lib/rapor-maske.ts Normal file
View File

@@ -0,0 +1,73 @@
// Paketsiz kullanıcıya giden raporun sunucu tarafı maskesi.
// Blur yalnızca CSS olduğundan kilitli satırların gerçek içeriği client'a
// hiç inmemeli: ilk ACIK_SATIR tercih gerçek kalır, gerisinin metinleri
// makul görünen placeholder'larla değiştirilir. Risk rengini besleyen
// dilim + il gerçek kalır ki blur altındaki dağılım adayı yansıtsın;
// taban sıralaması (efektifSira) sızdırılmaz.
import type { RaporSonuc } from "./ai/rapor";
export const ACIK_SATIR = 3;
const SAHTE_ISIMLER = [
"Bilgisayar Mühendisliği (İngilizce)",
"Elektrik-Elektronik Mühendisliği",
"Endüstri Mühendisliği",
"Yazılım Mühendisliği (Burslu)",
"Makine Mühendisliği",
"İşletme (İngilizce)",
"Psikoloji",
"Mimarlık",
];
const SAHTE_GEREKCE =
"Sıralamana ve seçtiğin önceliklere göre dengeli bir aday; ayrıntılı gerekçe kilidin ardında.";
const SAHTE_RISK =
"Yerleşme ihtimali değerlendirmesi paketi aktive edince görünür.";
const SAHTE_TREND =
"Son 4 yılın taban sıralaması hareketi paketi aktive edince görünür.";
/** genelDegerlendirme'nin yalnızca ilk 1-2 cümlesini bırakır. */
function degerlendirmeKirp(metin: string): string {
const cumleler = metin.match(/[^.!?]+[.!?]+/g);
if (!cumleler || cumleler.length <= 2) return metin;
return `${cumleler.slice(0, 2).join(" ").trim()}`;
}
export function raporMaskele(rapor: RaporSonuc): RaporSonuc {
const tercihler = rapor.tercihler.map((t, i) =>
i < ACIK_SATIR
? t
: {
...t,
gerekce: SAHTE_GEREKCE,
riskNotu: SAHTE_RISK,
trendOzeti: SAHTE_TREND,
},
);
const programlar: RaporSonuc["programlar"] = {};
rapor.tercihler.forEach((t, i) => {
const p = rapor.programlar[t.programId];
if (!p) return;
programlar[t.programId] =
i < ACIK_SATIR
? p
: {
isim: SAHTE_ISIMLER[i % SAHTE_ISIMLER.length],
universite: "•••••••• Üniversitesi",
il: p.il,
unitur: p.unitur,
efektifSira: null,
};
});
return {
tercihler,
genelDegerlendirme: degerlendirmeKirp(rapor.genelDegerlendirme),
// Uyarılar liste geneline dair, satır sırrı taşımaz: paketsizde de kalır.
// Satır bazlı siraGecmisi ise kilitli satırlarda hiç kopyalanmaz (üstte).
uyarilar: rapor.uyarilar,
programlar,
};
}

249
src/lib/uni-koordinatlar.ts Normal file
View File

@@ -0,0 +1,249 @@
// Üniversite kampüsü ve il merkezi koordinatları (lat, lng).
// Kaynak: kamuya açık kampüs konumları; hassasiyet il-içi pin göstermeye
// yeterli olacak düzeyde tutuldu (birkaç yüz metre sapma önemsiz).
//
// Anahtar biçimi: DB'deki üniversite adının "(İL)" eki ayıklanmış hali.
// Aynı ad birden çok ilde kampüs taşıyorsa (ör. Sağlık Bilimleri Üni.)
// "AD|İL" biçimindeki anahtar önceliklidir. Burada olmayan üniversiteler
// il merkezine (IL_MERKEZ) düşer; o da yoksa (KKTC, yurtdışı) harita dışıdır.
export type LatLng = readonly [number, number];
export const UNI_KOORDINAT: Record<string, LatLng> = {
// ── İstanbul ──────────────────────────────────────────────
"ACIBADEM MEHMET ALİ AYDINLAR ÜNİVERSİTESİ": [40.973, 29.129],
"ALTINBAŞ ÜNİVERSİTESİ": [41.058, 28.807],
"ATAŞEHİR ADIGÜZEL MESLEK YÜKSEKOKULU": [40.993, 29.123],
"BAHÇEŞEHİR ÜNİVERSİTESİ": [41.042, 29.009],
"BEYKOZ ÜNİVERSİTESİ": [41.093, 29.093],
"BEZM-İ ÂLEM VAKIF ÜNİVERSİTESİ": [41.017, 28.945],
"BOĞAZİÇİ ÜNİVERSİTESİ": [41.083, 29.051],
"BİRUNİ ÜNİVERSİTESİ": [40.989, 28.899],
"DEMİROĞLU BİLİM ÜNİVERSİTESİ": [41.06, 28.988],
"DOĞUŞ ÜNİVERSİTESİ": [41.018, 29.157],
"FATİH SULTAN MEHMET VAKIF ÜNİVERSİTESİ": [41.041, 28.945],
"FENERBAHÇE ÜNİVERSİTESİ": [40.983, 29.099],
"GALATASARAY ÜNİVERSİTESİ": [41.047, 29.026],
"HALİÇ ÜNİVERSİTESİ": [41.048, 28.945],
"IŞIK ÜNİVERSİTESİ": [41.109, 29.61],
"KADİR HAS ÜNİVERSİTESİ": [41.025, 28.958],
"KOÇ ÜNİVERSİTESİ": [41.204, 29.073],
"MALTEPE ÜNİVERSİTESİ": [40.943, 29.13],
"MARMARA ÜNİVERSİTESİ": [40.987, 29.053],
"MEF ÜNİVERSİTESİ": [41.107, 29.011],
"MİMAR SİNAN GÜZEL SANATLAR ÜNİVERSİTESİ": [41.031, 28.992],
"PİRİ REİS ÜNİVERSİTESİ": [40.811, 29.269],
"SABANCI ÜNİVERSİTESİ": [40.891, 29.379],
"SAĞLIK BİLİMLERİ ÜNİVERSİTESİ|İSTANBUL": [41.007, 29.016],
"TÜRK-ALMAN ÜNİVERSİTESİ": [41.148, 29.1],
"YEDİTEPE ÜNİVERSİTESİ": [40.973, 29.15],
"YILDIZ TEKNİK ÜNİVERSİTESİ": [41.021, 28.888],
"ÖZYEĞİN ÜNİVERSİTESİ": [41.031, 29.259],
"ÜSKÜDAR ÜNİVERSİTESİ": [41.022, 29.024],
"İBN HALDUN ÜNİVERSİTESİ": [41.107, 28.762],
"İSTANBUL 29 MAYIS ÜNİVERSİTESİ": [41.031, 29.098],
"İSTANBUL AREL ÜNİVERSİTESİ": [41.061, 28.532],
"İSTANBUL ATLAS ÜNİVERSİTESİ": [41.086, 28.978],
"İSTANBUL AYDIN ÜNİVERSİTESİ": [40.992, 28.797],
"İSTANBUL BEYKENT ÜNİVERSİTESİ": [41.111, 29.02],
"İSTANBUL BİLGİ ÜNİVERSİTESİ": [41.062, 28.947],
"İSTANBUL ESENYURT ÜNİVERSİTESİ": [41.034, 28.68],
"İSTANBUL GALATA ÜNİVERSİTESİ": [41.028, 28.974],
"İSTANBUL GEDİK ÜNİVERSİTESİ": [40.906, 29.23],
"İSTANBUL GELİŞİM ÜNİVERSİTESİ": [40.995, 28.708],
"İSTANBUL KENT ÜNİVERSİTESİ": [41.037, 28.972],
"İSTANBUL KÜLTÜR ÜNİVERSİTESİ": [40.991, 28.822],
"İSTANBUL MEDENİYET ÜNİVERSİTESİ": [40.999, 29.068],
"İSTANBUL MEDİPOL ÜNİVERSİTESİ": [41.089, 29.086],
"İSTANBUL NİŞANTAŞI ÜNİVERSİTESİ": [41.111, 29.023],
"İSTANBUL OKAN ÜNİVERSİTESİ": [40.94, 29.32],
"İSTANBUL RUMELİ ÜNİVERSİTESİ": [41.117, 28.204],
"İSTANBUL SABAHATTİN ZAİM ÜNİVERSİTESİ": [41.031, 28.783],
"İSTANBUL TEKNİK ÜNİVERSİTESİ": [41.105, 29.025],
"İSTANBUL TOPKAPI ÜNİVERSİTESİ": [41.021, 28.917],
"İSTANBUL TİCARET ÜNİVERSİTESİ": [41.048, 28.947],
"İSTANBUL YENİ YÜZYIL ÜNİVERSİTESİ": [41.004, 28.911],
"İSTANBUL ÜNİVERSİTESİ": [41.013, 28.964],
"İSTANBUL ÜNİVERSİTESİ-CERRAHPAŞA": [41.003, 28.725],
"İSTANBUL ŞİŞLİ MESLEK YÜKSEKOKULU": [41.06, 28.987],
"İSTİNYE ÜNİVERSİTESİ": [41.023, 28.925],
// ── Ankara ────────────────────────────────────────────────
"ANKARA HACI BAYRAM VELİ ÜNİVERSİTESİ": [39.938, 32.822],
"ANKARA MEDİPOL ÜNİVERSİTESİ": [39.943, 32.854],
"ANKARA SOSYAL BİLİMLER ÜNİVERSİTESİ": [39.943, 32.854],
"ANKARA YILDIRIM BEYAZIT ÜNİVERSİTESİ": [40.058, 32.947],
"ANKARA ÜNİVERSİTESİ": [39.938, 32.836],
"ATILIM ÜNİVERSİTESİ": [39.813, 32.729],
"BAŞKENT ÜNİVERSİTESİ": [39.888, 32.658],
"GAZİ ÜNİVERSİTESİ": [39.941, 32.822],
"HACETTEPE ÜNİVERSİTESİ": [39.867, 32.735],
"LOKMAN HEKİM ÜNİVERSİTESİ": [39.911, 32.799],
"ORTA DOĞU TEKNİK ÜNİVERSİTESİ": [39.891, 32.783],
"OSTİM TEKNİK ÜNİVERSİTESİ": [39.972, 32.758],
"SAĞLIK BİLİMLERİ ÜNİVERSİTESİ|ANKARA": [39.967, 32.887],
"TED ÜNİVERSİTESİ": [39.918, 32.867],
"TOBB EKONOMİ VE TEKNOLOJİ ÜNİVERSİTESİ": [39.919, 32.799],
"TÜRK HAVA KURUMU ÜNİVERSİTESİ": [39.949, 32.688],
"UFUK ÜNİVERSİTESİ": [39.892, 32.815],
"ÇANKAYA ÜNİVERSİTESİ": [39.818, 32.565],
"İHSAN DOĞRAMACI BİLKENT ÜNİVERSİTESİ": [39.868, 32.748],
// ── İzmir ─────────────────────────────────────────────────
"DOKUZ EYLÜL ÜNİVERSİTESİ": [38.368, 27.203],
"EGE ÜNİVERSİTESİ": [38.457, 27.23],
"YAŞAR ÜNİVERSİTESİ": [38.454, 27.2],
"İZMİR BAKIRÇAY ÜNİVERSİTESİ": [38.588, 27.06],
"İZMİR DEMOKRASİ ÜNİVERSİTESİ": [38.383, 27.096],
"İZMİR EKONOMİ ÜNİVERSİTESİ": [38.389, 27.049],
"İZMİR KATİP ÇELEBİ ÜNİVERSİTESİ": [38.483, 27.055],
"İZMİR KAVRAM MESLEK YÜKSEKOKULU": [38.415, 27.14],
"İZMİR TINAZTEPE ÜNİVERSİTESİ": [38.352, 27.185],
"İZMİR YÜKSEK TEKNOLOJİ ENSTİTÜSÜ": [38.319, 26.64],
// ── İlçe kampüsleri (il merkezinden belirgin uzakta) ──────
"ALANYA ALAADDİN KEYKUBAT ÜNİVERSİTESİ": [36.545, 31.996],
"ALANYA ÜNİVERSİTESİ": [36.552, 32.012],
"BANDIRMA ONYEDİ EYLÜL ÜNİVERSİTESİ": [40.352, 27.973],
"GEBZE TEKNİK ÜNİVERSİTESİ": [40.808, 29.359],
"TARSUS ÜNİVERSİTESİ": [36.919, 34.892],
"İSKENDERUN TEKNİK ÜNİVERSİTESİ": [36.577, 36.172],
"HATAY MUSTAFA KEMAL ÜNİVERSİTESİ": [36.32, 36.194],
"KAPADOKYA ÜNİVERSİTESİ": [38.446, 34.855],
// ── Diğer büyük kampüsler ─────────────────────────────────
"ABDULLAH GÜL ÜNİVERSİTESİ": [38.738, 35.472],
"ADANA ALPARSLAN TÜRKEŞ BİLİM VE TEKNOLOJİ ÜNİVERSİTESİ": [37.058, 35.442],
"AKDENİZ ÜNİVERSİTESİ": [36.897, 30.65],
"ANADOLU ÜNİVERSİTESİ": [39.79, 30.502],
"ANTALYA BELEK ÜNİVERSİTESİ": [36.897, 30.749],
"ANTALYA BİLİM ÜNİVERSİTESİ": [36.916, 30.795],
"ATATÜRK ÜNİVERSİTESİ": [39.902, 41.24],
"AYDIN ADNAN MENDERES ÜNİVERSİTESİ": [37.855, 27.851],
"BOLU ABANT İZZET BAYSAL ÜNİVERSİTESİ": [40.712, 31.523],
"BURSA TEKNİK ÜNİVERSİTESİ": [40.229, 29.009],
"BURSA ULUDAĞ ÜNİVERSİTESİ": [40.226, 28.87],
"ÇUKUROVA ÜNİVERSİTESİ": [37.058, 35.358],
"DİCLE ÜNİVERSİTESİ": [37.888, 40.276],
"ERCİYES ÜNİVERSİTESİ": [38.703, 35.526],
"ERZURUM TEKNİK ÜNİVERSİTESİ": [39.945, 41.181],
"ESKİŞEHİR OSMANGAZİ ÜNİVERSİTESİ": [39.751, 30.483],
"ESKİŞEHİR TEKNİK ÜNİVERSİTESİ": [39.814, 30.531],
"FIRAT ÜNİVERSİTESİ": [38.679, 39.199],
"GAZİANTEP ÜNİVERSİTESİ": [37.106, 37.362],
"GAZİANTEP İSLAM BİLİM VE TEKNOLOJİ ÜNİVERSİTESİ": [37.11, 37.42],
"HARRAN ÜNİVERSİTESİ": [37.008, 38.828],
"HASAN KALYONCU ÜNİVERSİTESİ": [37.14, 37.483],
"KAHRAMANMARAŞ SÜTÇÜ İMAM ÜNİVERSİTESİ": [37.629, 36.822],
"KARADENİZ TEKNİK ÜNİVERSİTESİ": [40.994, 39.766],
"KOCAELİ ÜNİVERSİTESİ": [40.822, 29.923],
"KONYA GIDA VE TARIM ÜNİVERSİTESİ": [37.939, 32.516],
"KONYA TEKNİK ÜNİVERSİTESİ": [38.026, 32.51],
"KTO KARATAY ÜNİVERSİTESİ": [37.884, 32.494],
"MANİSA CELÂL BAYAR ÜNİVERSİTESİ": [38.679, 27.311],
"MERSİN ÜNİVERSİTESİ": [36.786, 34.525],
"MUĞLA SITKI KOÇMAN ÜNİVERSİTESİ": [37.16, 28.372],
"NECMETTİN ERBAKAN ÜNİVERSİTESİ": [37.936, 32.504],
"NUH NACİ YAZGAN ÜNİVERSİTESİ": [38.756, 35.517],
"ONDOKUZ MAYIS ÜNİVERSİTESİ": [41.368, 36.2],
"PAMUKKALE ÜNİVERSİTESİ": [37.741, 29.101],
"SAKARYA ÜNİVERSİTESİ": [40.741, 30.331],
"SANKO ÜNİVERSİTESİ": [37.076, 37.331],
"SELÇUK ÜNİVERSİTESİ": [38.023, 32.512],
"SÜLEYMAN DEMİREL ÜNİVERSİTESİ": [37.83, 30.526],
"SİVAS CUMHURİYET ÜNİVERSİTESİ": [39.727, 37.05],
"TOROS ÜNİVERSİTESİ": [36.786, 34.583],
"TRABZON ÜNİVERSİTESİ": [40.999, 39.65],
"TRAKYA ÜNİVERSİTESİ": [41.66, 26.554],
"VAN YÜZÜNCÜ YIL ÜNİVERSİTESİ": [38.567, 43.276],
"ÇAĞ ÜNİVERSİTESİ": [36.925, 34.955],
"ÇANAKKALE ONSEKİZ MART ÜNİVERSİTESİ": [40.117, 26.42],
"İNÖNÜ ÜNİVERSİTESİ": [38.318, 38.435],
"ZONGULDAK BÜLENT ECEVİT ÜNİVERSİTESİ": [41.448, 31.767],
};
// İl merkezi koordinatları — DB'deki il adı biçimiyle (büyük harf, Türkçe).
// Koordinatı bilinmeyen üniversiteler bu noktaya düşer.
export const IL_MERKEZ: Record<string, LatLng> = {
ADANA: [37.0, 35.32],
ADIYAMAN: [37.76, 38.28],
AFYONKARAHİSAR: [38.76, 30.54],
AĞRI: [39.72, 43.05],
AKSARAY: [38.37, 34.03],
AMASYA: [40.65, 35.83],
ANKARA: [39.93, 32.86],
ANTALYA: [36.9, 30.7],
ARDAHAN: [41.11, 42.7],
ARTVİN: [41.18, 41.82],
AYDIN: [37.85, 27.85],
BALIKESİR: [39.65, 27.89],
BARTIN: [41.63, 32.34],
BATMAN: [37.88, 41.13],
BAYBURT: [40.26, 40.22],
BİLECİK: [40.15, 29.98],
BİNGÖL: [38.88, 40.5],
BİTLİS: [38.4, 42.11],
BOLU: [40.74, 31.61],
BURDUR: [37.72, 30.29],
BURSA: [40.18, 29.07],
ÇANAKKALE: [40.15, 26.41],
ÇANKIRI: [40.6, 33.62],
ÇORUM: [40.55, 34.95],
DENİZLİ: [37.77, 29.09],
DİYARBAKIR: [37.91, 40.24],
DÜZCE: [40.84, 31.16],
EDİRNE: [41.68, 26.56],
ELAZIĞ: [38.68, 39.22],
ERZİNCAN: [39.75, 39.49],
ERZURUM: [39.9, 41.27],
ESKİŞEHİR: [39.78, 30.52],
GAZİANTEP: [37.07, 37.38],
GİRESUN: [40.91, 38.39],
GÜMÜŞHANE: [40.46, 39.48],
HAKKARİ: [37.57, 43.74],
HATAY: [36.2, 36.16],
IĞDIR: [39.92, 44.04],
ISPARTA: [37.76, 30.56],
İSTANBUL: [41.01, 28.98],
İZMİR: [38.42, 27.14],
KAHRAMANMARAŞ: [37.58, 36.93],
KARABÜK: [41.2, 32.63],
KARAMAN: [37.18, 33.22],
KARS: [40.6, 43.1],
KASTAMONU: [41.38, 33.78],
KAYSERİ: [38.72, 35.49],
KIRIKKALE: [39.85, 33.51],
KIRKLARELİ: [41.73, 27.22],
KIRŞEHİR: [39.15, 34.16],
KİLİS: [36.72, 37.12],
KOCAELİ: [40.77, 29.94],
KONYA: [37.87, 32.48],
KÜTAHYA: [39.42, 29.98],
MALATYA: [38.35, 38.31],
MANİSA: [38.61, 27.43],
MARDİN: [37.31, 40.74],
MERSİN: [36.8, 34.63],
MUĞLA: [37.22, 28.36],
MUŞ: [38.75, 41.5],
NEVŞEHİR: [38.62, 34.71],
NİĞDE: [37.97, 34.68],
ORDU: [40.98, 37.88],
OSMANİYE: [37.07, 36.25],
RİZE: [41.02, 40.52],
SAKARYA: [40.77, 30.4],
SAMSUN: [41.29, 36.33],
SİİRT: [37.93, 41.94],
SİNOP: [42.03, 35.15],
SİVAS: [39.75, 37.02],
ŞANLIURFA: [37.16, 38.79],
ŞIRNAK: [37.52, 42.46],
TEKİRDAĞ: [40.98, 27.51],
TOKAT: [40.31, 36.55],
TRABZON: [41.0, 39.72],
TUNCELİ: [39.11, 39.55],
UŞAK: [38.68, 29.4],
VAN: [38.5, 43.38],
YALOVA: [40.65, 29.27],
YOZGAT: [39.82, 34.81],
ZONGULDAK: [41.45, 31.79],
};