diff --git a/src/app/api/facetler/route.ts b/src/app/api/facetler/route.ts new file mode 100644 index 0000000..ffd2d0a --- /dev/null +++ b/src/app/api/facetler/route.ts @@ -0,0 +1,22 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { PUAN_TURLERI, rankWindowFacets, type PuanTuruKey } from "@/lib/db"; + +// Ana sayfadaki sihirbaz modalı için: sıralamaya uygun kategori/il/üniversite +// tipi facet'leri. /sonuc bu veriyi server component'te doğrudan alır; burada +// client'tan sıra girildikten sonra çekilir. +export function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const sira = Number.parseInt(searchParams.get("sira") ?? "", 10); + const turParam = searchParams.get("tur") ?? "say"; + const tur: PuanTuruKey = + turParam in PUAN_TURLERI ? (turParam as PuanTuruKey) : "say"; + + if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) { + return NextResponse.json( + { error: "Geçerli bir sıralama gerekli." }, + { status: 400 }, + ); + } + + return NextResponse.json({ facetler: rankWindowFacets(sira, tur) }); +} diff --git a/src/app/sonuc/page.tsx b/src/app/sonuc/page.tsx index 30ca170..7ae766b 100644 --- a/src/app/sonuc/page.tsx +++ b/src/app/sonuc/page.tsx @@ -1,22 +1,11 @@ import Link from "next/link"; import type { Metadata } from "next"; -import { - ArrowLeft, - MapPin, - Minus, - Rocket, - Scale, - ShieldCheck, - TrendingDown, - TrendingUp, - X, -} from "lucide-react"; +import { ArrowLeft } from "lucide-react"; import { eq } from "drizzle-orm"; import { PUAN_TURLERI, searchByRank, rankWindowFacets, - type Program, type PuanTuruKey, } from "@/lib/db"; import { getSession, getCurrentUser } from "@/lib/session"; @@ -24,24 +13,12 @@ import { appDb, schema } from "@/lib/appdb"; import type { RaporSonuc } from "@/lib/ai/rapor"; import type { RaporParams } from "@/lib/rapor-havuzu"; import { SihirbazBolumu } from "./sihirbaz-cagri-karti"; -import type { - MevcutRapor, - OnizlemeSatir, -} from "./sihirbaz-modal"; -import { BolgeHaritasi, type IlOzeti } from "@/components/bolge-haritasi"; +import type { MevcutRapor, OnizlemeSatir } from "./sihirbaz-modal"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; export const metadata: Metadata = { - title: "Sıralamana Uygun Programlar — KolayTercih", + title: "Tercih Listeni Kur — KolayTercih", }; const TUR_LABELS: Record = { @@ -52,106 +29,12 @@ const TUR_LABELS: Record = { tyt: "TYT (Önlisans)", }; -function TrendIcon({ p }: { p: Program }) { - if (p.sira2025 == null || p.sira2024 == null) { - return ; - } - const degisim = (p.sira2024 - p.sira2025) / p.sira2024; - // taban sıralaması küçülüyorsa bölüm zorlaşıyor demektir - if (degisim > 0.05) { - return ( - - - zorlaşıyor - - ); - } - if (degisim < -0.05) { - return ( - - - rahatlıyor - - ); - } - return ( - - - stabil - - ); -} - -function fmt(n: number | null): string { - return n == null ? "—" : n.toLocaleString("tr-TR"); -} - -function ProgramTable({ programs }: { programs: Program[] }) { - if (programs.length === 0) { - return ( -

- Bu dilimde uygun program bulunamadı. -

- ); - } - return ( -
- - - - Program - Üniversite - 2025 taban sırası - 2024 - Trend - Kontenjan - - - - {programs.map((p) => ( - - - {p.isim} - {p.fakulte ? ( - - {p.fakulte} - - ) : null} - - - {p.universite} - - {p.il} - {p.unitur ? ` · ${p.unitur.toLocaleLowerCase("tr-TR")}` : ""} - - - - {fmt(p.sira2025)} - - - {fmt(p.sira2024)} - - - - - - {fmt(p.kontenjan2025)} - - - ))} - -
-
- ); -} - export default async function SonucPage({ searchParams, }: { searchParams: Promise<{ sira?: string; tur?: string; - il?: string; sihirbaz?: string; }>; }) { @@ -161,7 +44,6 @@ export default async function SonucPage({ params.tur && params.tur in PUAN_TURLERI ? (params.tur as PuanTuruKey) : "say"; - const seciliIl = params.il?.trim() || null; if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) { return ( @@ -170,7 +52,7 @@ export default async function SonucPage({ Geçerli bir sıralama gerekli

- Sonuçları görebilmek için ana sayfadan YKS başarı sıralamanı gir. + Listeni kurabilmek için ana sayfadan YKS başarı sıralamanı gir.

))} - {seciliIl ? ( -
- -

- Yalnızca {seciliIl}{" "} - programları gösteriliyor -

- -
- ) : null} - - {/* Bölge haritası */} -
-

- Nerede okuyacaksın? -

-

- Aşağıdaki önerilerin illere dağılımı. Koyu renk, o ilde daha fazla - öneri olduğunu gösterir. Bir ile tıklayarak listeyi o ile - daraltabilir, tekrar tıklayarak filtreyi kaldırabilirsin. -

-
- -
-
- -
- {sections.map((s) => ( -
-
- -
-

- {s.title} - - {s.programs.length} program - -

-

{s.description}

-
-
- -
- ))} -
- - {/* Ücretli katman — sihirbaz akışı (CTA + alt kart + modal) */} + {/* 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 */} void; - children: React.ReactNode; -}) { - return ( - - ); -} - const URETIM_MESAJLARI = [ "Sıralamana uygun programlar taranıyor…", "Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…", @@ -115,48 +85,19 @@ export function SihirbazModal({ const router = useRouter(); const [faz, setFaz] = useState(mevcutRapor ? "liste" : "adimlar"); - const [adim, setAdim] = useState(0); const [kredi, setKredi] = useState(baslangicKredi); const [rapor, setRapor] = useState(mevcutRapor); const [uretimMesajIdx, setUretimMesajIdx] = useState(0); - // Seçimler - const [kategoriler, setKategoriler] = useState( - baslangicSecimler?.kategoriler ?? [], - ); - const [iller, setIller] = useState(baslangicSecimler?.iller ?? []); - const [universiteTipi, setUniversiteTipi] = useState( - baslangicSecimler?.universiteTipi ?? "farketmez", - ); - const [oncelikler, setOncelikler] = useState( - baslangicSecimler?.oncelikler ?? [], + // Adımlarda yapılan son seçimler (önizlemeden geri dönüşte prefill için) + const [sonSecimler, setSonSecimler] = useState( + baslangicSecimler ?? null, ); // Aynı üretim için sabit requestId (retry çifte harcama yapmaz) const requestIdRef = useRef(null); const otomatikBasladi = useRef(false); - function secimleriTopla(): SihirbazSecimleri { - return { kategoriler, iller, universiteTipi, oncelikler }; - } - - function listeDegistir( - liste: string[], - setListe: (v: string[]) => void, - deger: string, - maks?: number, - ) { - if (liste.includes(deger)) { - setListe(liste.filter((x) => x !== deger)); - } else { - if (maks && liste.length >= maks) { - toast.info(`En fazla ${maks} seçebilirsin.`); - return; - } - setListe([...liste, deger]); - } - } - async function uret(secimler: SihirbazSecimleri) { if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID(); setFaz("uretiliyor"); @@ -241,8 +182,8 @@ export function SihirbazModal({ } catch {} } - function ilerle() { - const secimler = secimleriTopla(); + function ilerle(secimler: SihirbazSecimleri) { + setSonSecimler(secimler); if (!girisliMi) { secimleriKaydetVeGirise(secimler); return; @@ -255,162 +196,17 @@ export function SihirbazModal({ void uret(secimler); } - const adimGecerli = adim === 0 ? kategoriler.length > 0 : true; - // ---- Faz gövdeleri ---- const adimlarGovde = ( -
-
- {[0, 1, 2].map((i) => ( - - ))} - - Adım {adim + 1}/3 - -
- - {adim === 0 ? ( -
-

- Hangi alanlara ilgi duyuyorsun? -

-

- Sıralamanla ulaşabileceğin alanlar. En az bir tane seç. -

-
- {facetler.kategoriler.map((k) => ( - - listeDegistir(kategoriler, setKategoriler, k.ad) - } - > - {k.ad} - {k.adet} - - ))} -
-
- ) : adim === 1 ? ( -
-

- Nerede okumak istersin? -

-

- İstersen boş bırak (fark etmez). En fazla 5 il. -

-
- {facetler.iller.map((i) => ( - listeDegistir(iller, setIller, i.il, 5)} - > - {i.il} - {i.adet} - - ))} -
-
- ) : ( -
-
-

- Üniversite tipi & öncelikler -

-

- Bunlar listenin dengesini belirler. -

-
-
-

- Üniversite tipi -

-
- {facetler.uniturler.map((u) => ( - - setUniversiteTipi( - universiteTipi === u.grup ? "farketmez" : u.grup, - ) - } - > - {UNIVERSITE_TIPI_ETIKET[u.grup]} - {u.adet} - - ))} - setUniversiteTipi("farketmez")} - > - Fark etmez - -
-
-
-

- Senin için önemli olan -

-
- {ONCELIKLER.map((o) => ( - listeDegistir(oncelikler, setOncelikler, o)} - > - {o} - - ))} -
-
-
- )} - -
- - {adim < 2 ? ( - - ) : ( - - )} -
-
+ ); const onizlemeGovde = ( @@ -517,7 +313,7 @@ export function SihirbazModal({
- +
@@ -544,8 +340,22 @@ export function SihirbazModal({ />
-
- +
+
+

+ Listen hakkında soruların mı var? +

+

+ AI danışman listeni ve seçimlerini bilir; her mesaj 1 kredi. +

+
+
) : null; diff --git a/src/components/hero-form.tsx b/src/components/hero-form.tsx index 7464469..13a3c7b 100644 --- a/src/components/hero-form.tsx +++ b/src/components/hero-form.tsx @@ -2,49 +2,112 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { ArrowRight } from "lucide-react"; +import { ArrowRight, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar"; +import type { SihirbazFacetleri } from "@/lib/db"; +import { SIHIRBAZ_STORAGE_KEY, type SihirbazSecimleri } from "@/lib/sihirbaz"; + +const TUR: string = "say"; export function HeroForm() { const [siralama, setSiralama] = useState(""); + const [sira, setSira] = useState(null); + const [facetler, setFacetler] = useState(null); + const [yukleniyor, setYukleniyor] = useState(false); + const [modalAcik, setModalAcik] = useState(false); const router = useRouter(); - function handleSubmit(e: React.FormEvent) { + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const value = Number(siralama.replace(/\./g, "")); if (!value || value < 1 || value > 4_000_000) { toast.error("Geçerli bir başarı sıralaması gir (ör. 85.000)."); return; } - router.push(`/sonuc?sira=${value}`); + setYukleniyor(true); + try { + const res = await fetch(`/api/facetler?sira=${value}&tur=${TUR}`); + if (!res.ok) throw new Error(); + const veri = (await res.json()) as { facetler: SihirbazFacetleri }; + setSira(value); + setFacetler(veri.facetler); + setModalAcik(true); + } catch { + toast.error("Bir şeyler ters gitti, tekrar dener misin?"); + } finally { + setYukleniyor(false); + } + } + + // Seçimler tamam: sonuç sayfası (ve sonrası) kullanabilsin diye sakla, yönlendir. + function secimleriTamamla(secimler: SihirbazSecimleri) { + if (!sira) return; + try { + localStorage.setItem( + SIHIRBAZ_STORAGE_KEY, + JSON.stringify({ sira, tur: TUR, secimler }), + ); + } catch {} + setModalAcik(false); + router.push(`/sonuc?sira=${sira}&tur=${TUR}`); } return ( -
- - setSiralama(e.target.value)} - className="h-12 flex-1 bg-white text-base" - /> - -
+ + setSiralama(e.target.value)} + className="h-12 flex-1 bg-white text-base" + /> + + + + + + Tercih listesi sihirbazı + + Sıralamana uygun seçimlerini yap, tercih planına geç. + + {facetler ? ( + + ) : null} + + + ); } diff --git a/src/components/sihirbaz-adimlar.tsx b/src/components/sihirbaz-adimlar.tsx new file mode 100644 index 0000000..7299e2f --- /dev/null +++ b/src/components/sihirbaz-adimlar.tsx @@ -0,0 +1,239 @@ +"use client"; + +// Sihirbazın 3 adımlık seçim gövdesi. Hem ana sayfadaki hero modalında hem de +// /sonuc'taki sihirbaz modalında kullanılır; seçim state'ini kendi tutar, +// tamamlanınca onTamamla(secimler) çağırır. + +import { useState } from "react"; +import { ArrowLeft, ArrowRight, Sparkles } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import type { SihirbazFacetleri } from "@/lib/db"; +import { + ONCELIKLER, + UNIVERSITE_TIPI_ETIKET, + type SihirbazSecimleri, + type UniversiteTipi, +} from "@/lib/sihirbaz"; + +export function Cip({ + secili, + onClick, + children, +}: { + secili: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +export function SihirbazAdimlar({ + facetler, + baslangicSecimler, + sonButonEtiketi, + onTamamla, +}: { + facetler: SihirbazFacetleri; + baslangicSecimler?: SihirbazSecimleri | null; + sonButonEtiketi: string; + onTamamla: (secimler: SihirbazSecimleri) => void; +}) { + const [adim, setAdim] = useState(0); + const [kategoriler, setKategoriler] = useState( + baslangicSecimler?.kategoriler ?? [], + ); + const [iller, setIller] = useState(baslangicSecimler?.iller ?? []); + const [universiteTipi, setUniversiteTipi] = useState( + baslangicSecimler?.universiteTipi ?? "farketmez", + ); + const [oncelikler, setOncelikler] = useState( + baslangicSecimler?.oncelikler ?? [], + ); + + function listeDegistir( + liste: string[], + setListe: (v: string[]) => void, + deger: string, + maks?: number, + ) { + if (liste.includes(deger)) { + setListe(liste.filter((x) => x !== deger)); + } else { + if (maks && liste.length >= maks) { + toast.info(`En fazla ${maks} seçebilirsin.`); + return; + } + setListe([...liste, deger]); + } + } + + const adimGecerli = adim === 0 ? kategoriler.length > 0 : true; + + return ( +
+
+ {[0, 1, 2].map((i) => ( + + ))} + + Adım {adim + 1}/3 + +
+ + {adim === 0 ? ( +
+

+ Hangi alanlara ilgi duyuyorsun? +

+

+ Sıralamanla ulaşabileceğin alanlar. En az bir tane seç. +

+
+ {facetler.kategoriler.map((k) => ( + listeDegistir(kategoriler, setKategoriler, k.ad)} + > + {k.ad} + {k.adet} + + ))} +
+
+ ) : adim === 1 ? ( +
+

+ Nerede okumak istersin? +

+

+ İstersen boş bırak (fark etmez). En fazla 5 il. +

+
+ {facetler.iller.map((i) => ( + listeDegistir(iller, setIller, i.il, 5)} + > + {i.il} + {i.adet} + + ))} +
+
+ ) : ( +
+
+

+ Üniversite tipi & öncelikler +

+

+ Bunlar listenin dengesini belirler. +

+
+
+

+ Üniversite tipi +

+
+ {facetler.uniturler.map((u) => ( + + setUniversiteTipi( + universiteTipi === u.grup ? "farketmez" : u.grup, + ) + } + > + {UNIVERSITE_TIPI_ETIKET[u.grup]} + {u.adet} + + ))} + setUniversiteTipi("farketmez")} + > + Fark etmez + +
+
+
+

+ Senin için önemli olan +

+
+ {ONCELIKLER.map((o) => ( + listeDegistir(oncelikler, setOncelikler, o)} + > + {o} + + ))} +
+
+
+ )} + +
+ + {adim < 2 ? ( + + ) : ( + + )} +
+
+ ); +}