Update package.json scripts, enhance layout with new components, and improve API error handling
Some checks failed
Deploy / deploy (push) Failing after 1h28m6s
Some checks failed
Deploy / deploy (push) Failing after 1h28m6s
- Added new scripts for database operations and temporary production in package.json. - Integrated KaydetBannerLazy component into the layout for improved user notifications. - Enhanced API error handling in the soru route to mark credit exhaustion. - Updated the IletisimPage for better button styling and user experience. - Refactored ListemPage to streamline user flow and improve session handling. - Removed unused ListePaneli component to clean up the codebase.
This commit is contained in:
19
src/app/api/netler/route.ts
Normal file
19
src/app/api/netler/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { netlerGetir } from "@/lib/db";
|
||||
|
||||
// Program satırındaki "netler" butonunun verisi: programa yerleşen son
|
||||
// kişinin yıl bazlı netleri (bkz. components/program-netleri.tsx). Veri
|
||||
// yılda bir değiştiği için istemci tarafında gönül rahatlığıyla cache'lenir.
|
||||
export function GET(request: NextRequest) {
|
||||
const id = request.nextUrl.searchParams.get("id") ?? "";
|
||||
if (!/^\d{1,12}$/.test(id)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Geçerli bir program kimliği gerekli." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ netler: netlerGetir(id) },
|
||||
{ headers: { "Cache-Control": "public, max-age=86400" } },
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,11 @@ import { eq, desc, asc } from "drizzle-orm";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { spendCreditForMessage, grantCredits } from "@/lib/credits";
|
||||
import {
|
||||
spendCreditForMessage,
|
||||
grantCredits,
|
||||
krediBittiIsaretle,
|
||||
} from "@/lib/credits";
|
||||
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
|
||||
import { sohbetAkisi } from "@/lib/ai/cagri";
|
||||
import { listeOzetiCikar, type RaporSonuc } from "@/lib/ai/rapor";
|
||||
@@ -147,6 +151,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
if (!harcama.ok) {
|
||||
if (harcama.error === "INSUFFICIENT") {
|
||||
await krediBittiIsaretle(session.user.id);
|
||||
return Response.json(
|
||||
{ error: "Kredin bitti.", code: "INSUFFICIENT" },
|
||||
{ status: 402 },
|
||||
|
||||
38
src/app/api/tadimlik/route.ts
Normal file
38
src/app/api/tadimlik/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Tadımlık satırı servisi: client sihirbaz kategorisini (localStorage'da
|
||||
// yaşar, SSR göremez) bildirir; SEÇİM sunucuda yapılır (PRD kısıtı). Havuz
|
||||
// batch üretilmiştir — bu uç LLM'e istek atmaz, salt SELECT'tir.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
|
||||
import { KATEGORILER } from "@/lib/kategoriler";
|
||||
import { kategoriSlugBul, tadimlikSec } from "@/lib/tadimlik-havuzu";
|
||||
|
||||
const GECERLI_KATEGORI_SLUGLARI = new Set(KATEGORILER.map(kategoriSlugBul));
|
||||
|
||||
export async function GET(istek: Request) {
|
||||
const url = new URL(istek.url);
|
||||
const sira = Number.parseInt(url.searchParams.get("sira") ?? "", 10);
|
||||
const tur = url.searchParams.get("tur") ?? "";
|
||||
|
||||
if (
|
||||
!Number.isFinite(sira) ||
|
||||
sira < 1 ||
|
||||
sira > 4_000_000 ||
|
||||
!(tur in PUAN_TURLERI)
|
||||
) {
|
||||
return NextResponse.json({ tadimlik: null }, { status: 400 });
|
||||
}
|
||||
|
||||
const kategoriParam = url.searchParams.get("kategori");
|
||||
const kategoriSlug =
|
||||
kategoriParam && GECERLI_KATEGORI_SLUGLARI.has(kategoriParam)
|
||||
? kategoriParam
|
||||
: undefined;
|
||||
|
||||
const tadimlik = await tadimlikSec(sira, tur as PuanTuruKey, kategoriSlug);
|
||||
return NextResponse.json(
|
||||
{ tadimlik },
|
||||
// Satırlar kova granülaritesinde statik; 1 saat cache yeterli.
|
||||
{ headers: { "Cache-Control": "public, max-age=3600" } },
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export default function IletisimPage() {
|
||||
</p>
|
||||
<a
|
||||
href="mailto:destek@kolaytercih.com"
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-900 shadow-xs transition-colors hover:border-slate-300 hover:bg-slate-50"
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-900 shadow-xs transition-colors hover:border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
<Mail className="size-4 text-primary" aria-hidden />
|
||||
destek@kolaytercih.com
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Toaster } from "@/components/ui/sonner";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { SiteTopBanner } from "@/components/site-top-banner";
|
||||
import { ListeCekmecesiLazy } from "@/components/manuel-liste/liste-cekmecesi-lazy";
|
||||
import { KaydetBannerLazy } from "@/components/manuel-liste/kaydet-banner-lazy";
|
||||
import { TercihProfiliKapisiLazy } from "@/components/manuel-liste/tercih-profili-kapisi-lazy";
|
||||
import { HeaderGate } from "@/components/header-gate";
|
||||
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
|
||||
@@ -124,6 +125,8 @@ export default function RootLayout({
|
||||
</ScrollFlow>
|
||||
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
|
||||
<ListeCekmecesiLazy />
|
||||
{/* Girişsiz + 3 seçimli kullanıcıya "listen yalnızca bu tarayıcıda" banner'ı */}
|
||||
<KaydetBannerLazy />
|
||||
{/* İlk "+" için sıra + sihirbaz kapısı */}
|
||||
<TercihProfiliKapisiLazy />
|
||||
<Toaster position="top-center" />
|
||||
|
||||
208
src/app/listem/liste-uretici.tsx
Normal file
208
src/app/listem/liste-uretici.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
// /listem üretim gövdesi: sihirbazın kaydettiği bekleyen seçimlerle Yapay
|
||||
// Zeka listesi burada kurulur (sonuç sayfası yalnızca buraya yönlendirir).
|
||||
// Başarıda profil tüketilir ve sayfa yenilenir; kredi-yetersiz ve hata
|
||||
// durumları da bu bileşende karşılanır.
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Lock, RefreshCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { listeOlustur } from "@/app/sonuc/actions";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import {
|
||||
sonucHref,
|
||||
tercihProfiliOku,
|
||||
tercihProfiliSil,
|
||||
type TercihProfili,
|
||||
} from "@/lib/sihirbaz";
|
||||
|
||||
const URETIM_MESAJLARI = [
|
||||
"Sıralamana uygun programlar taranıyor…",
|
||||
"Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…",
|
||||
"Her tercih için risk ve trend analizi yazılıyor…",
|
||||
"Son rötuşlar yapılıyor, neredeyse hazır…",
|
||||
];
|
||||
|
||||
type Durum =
|
||||
| { ad: "uretiliyor" }
|
||||
| { ad: "kredi-yetersiz" }
|
||||
| { ad: "hata"; mesaj: string }
|
||||
| { ad: "profil-yok" };
|
||||
|
||||
export function ListeUretici({
|
||||
hasPaket,
|
||||
bosDurum,
|
||||
}: {
|
||||
hasPaket: boolean;
|
||||
/** Storage'da bekleyen seçim yoksa gösterilecek boş-liste durumu */
|
||||
bosDurum: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [durum, setDurum] = useState<Durum>({ ad: "uretiliyor" });
|
||||
const [profil, setProfil] = useState<TercihProfili | null>(null);
|
||||
// Aynı deneme için sabit requestId: cevap kaybolan network hatasında retry
|
||||
// çifte harcamaz. Sunucudan cevap gelince sıfırlanır (harcama ya tamamlandı
|
||||
// ya iade edildi; sonraki deneme taze id ile başlamalı — bkz. sonuc/actions.ts).
|
||||
const requestIdRef = useRef<string | null>(null);
|
||||
const basladi = useRef(false);
|
||||
|
||||
async function uret(profil: TercihProfili) {
|
||||
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
||||
setDurum({ ad: "uretiliyor" });
|
||||
|
||||
try {
|
||||
const sonuc = await listeOlustur({
|
||||
sira: profil.sira,
|
||||
tur: profil.tur,
|
||||
secimler: profil.secimler,
|
||||
requestId: requestIdRef.current,
|
||||
});
|
||||
if (sonuc.ok) {
|
||||
requestIdRef.current = null;
|
||||
olay("liste_olusturuldu", {
|
||||
tur: profil.tur,
|
||||
sira_kovasi: siraKovasi(profil.sira),
|
||||
});
|
||||
tercihProfiliSil();
|
||||
// ?uret=1 URL'de kalmasın: yenileme/geri dönüş üretimi tekrar
|
||||
// tetiklemesin. refresh sunucudaki raporu (ve header'daki kredi
|
||||
// pill'ini) ekrana getirir.
|
||||
window.history.replaceState(null, "", "/listem");
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
requestIdRef.current = null;
|
||||
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
|
||||
olay("kredi_bitti");
|
||||
setDurum({ ad: "kredi-yetersiz" });
|
||||
} else if (sonuc.code === "AUTH") {
|
||||
router.push(`/giris?callback=${encodeURIComponent("/listem?uret=1")}`);
|
||||
} else {
|
||||
setDurum({ ad: "hata", mesaj: sonuc.error });
|
||||
}
|
||||
} catch {
|
||||
setDurum({
|
||||
ad: "hata",
|
||||
mesaj: "Beklenmeyen bir hata oluştu, tekrar dener misin?",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (basladi.current) return;
|
||||
basladi.current = true;
|
||||
// Bekleyen seçimler yalnızca client storage'da; mount sonrası okunur.
|
||||
const kayitli = tercihProfiliOku();
|
||||
if (!kayitli) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setDurum({ ad: "profil-yok" });
|
||||
return;
|
||||
}
|
||||
setProfil(kayitli);
|
||||
void uret(kayitli);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (durum.ad === "profil-yok") return <>{bosDurum}</>;
|
||||
|
||||
if (durum.ad === "kredi-yetersiz") {
|
||||
// Paketli kullanıcıya paket satma: onun ürünü top-up. Liste ve sohbet
|
||||
// geçmişi kalıcı; kredi yükleyince kaldığı yerden devam eder.
|
||||
return (
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||
<Lock className="mx-auto size-8 text-slate-400" aria-hidden />
|
||||
<h1 className="mt-4 font-heading text-xl font-bold">
|
||||
Kredin bu liste için yetmiyor
|
||||
</h1>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
|
||||
{hasPaket
|
||||
? "Liste üretimi 3 kredi. +30 kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin."
|
||||
: "Liste üretimi 3 kredi. Tercih Dönemi Paketi'yle 60 kredi, tam liste, PDF ve revizyon hakları birlikte gelir."}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">
|
||||
{hasPaket ? "+30 kredi yükle — 129 TL" : "Paketi aktive et — 299 TL"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (durum.ad === "hata") {
|
||||
return (
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||
<h1 className="font-heading text-xl font-bold">Liste kurulamadı</h1>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
|
||||
{durum.mesaj}
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => profil && void uret(profil)}
|
||||
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<RefreshCcw className="size-4" aria-hidden />
|
||||
Tekrar dene
|
||||
</Button>
|
||||
{profil ? (
|
||||
<p className="mt-4 text-xs text-slate-500">
|
||||
Seçimlerin kayıtlı —{" "}
|
||||
<Link href={sonucHref(profil)} className="underline">
|
||||
sonuç sayfasına dönebilirsin
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return <UretimGovdesi />;
|
||||
}
|
||||
|
||||
function UretimGovdesi() {
|
||||
const [mesajIdx, setMesajIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
|
||||
}, 7000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-live="polite"
|
||||
className="rounded-2xl border border-slate-200 bg-white p-6 sm:p-8"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<h1 className="font-heading text-lg font-bold">Listen hazırlanıyor</h1>
|
||||
<p className="max-w-sm text-sm text-slate-500">
|
||||
{URETIM_MESAJLARI[mesajIdx]}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gelmekte olan tercih listesinin iskeleti */}
|
||||
<div className="mt-8 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-44" />
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function ListemBosCta() {
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white hover:bg-orange-600"
|
||||
className="mt-6 cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||
>
|
||||
<Link href={profil ? sonucHref(profil, { sihirbaz: "1" }) : "/"}>
|
||||
{profil ? "Yapay Zeka listeni kur" : "Sıralamanı gir, Yapay Zeka listeni kur"}
|
||||
|
||||
283
src/app/listem/listem-govde.tsx
Normal file
283
src/app/listem/listem-govde.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
// /listem gövdesi: Türkiye haritası + 24'lük liste + danışman sohbeti tek
|
||||
// ekranda. Harita ⇄ liste köprüsü (pinden satıra, satırdan haritaya) ve
|
||||
// paketsiz paywall kartları burada; veri çekimi sunucuda (page.tsx) kalır.
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Check,
|
||||
FileText,
|
||||
Lock,
|
||||
PencilLine,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RaporListesi } from "@/components/rapor-listesi";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { TercihHaritasi } from "@/components/tercih-haritasi";
|
||||
import { pinleriTuret } from "@/lib/harita-pinler";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import { SohbetClient, type DanismanOzeti, type Mesaj } from "./sohbet-client";
|
||||
import { RevizyonKutusu } from "./revizyon-kutusu";
|
||||
|
||||
export function ListemGovde({
|
||||
rapor,
|
||||
adaySira,
|
||||
hasPaket,
|
||||
kilitAcilisi,
|
||||
kilitliBaslangic,
|
||||
kredi,
|
||||
sohbetGecmisi,
|
||||
ozet,
|
||||
kalanHak,
|
||||
paketFiyati,
|
||||
paketKredi,
|
||||
}: {
|
||||
rapor: RaporSonuc;
|
||||
adaySira: number;
|
||||
hasPaket: boolean;
|
||||
/** Ödeme dönüşü: blur çözülme animasyonuyla aç */
|
||||
kilitAcilisi: boolean;
|
||||
/** Paketsizde ACIK_SATIR; paketlide undefined (tamamı açık) */
|
||||
kilitliBaslangic?: number;
|
||||
kredi: number;
|
||||
sohbetGecmisi: Mesaj[];
|
||||
ozet?: DanismanOzeti;
|
||||
kalanHak: number;
|
||||
paketFiyati: string;
|
||||
paketKredi: number;
|
||||
}) {
|
||||
// Harita ⇄ liste köprüsü: pinden satıra, satırdan haritaya
|
||||
const [seciliIl, setSeciliIl] = useState<string | null>(null);
|
||||
const [acikSira, setAcikSira] = useState<number | null>(null);
|
||||
const haritaRef = useRef<HTMLElement>(null);
|
||||
|
||||
const pinVerisi = useMemo(
|
||||
() =>
|
||||
pinleriTuret(
|
||||
rapor,
|
||||
adaySira,
|
||||
kilitliBaslangic ?? rapor.tercihler.length,
|
||||
),
|
||||
[rapor, adaySira, kilitliBaslangic],
|
||||
);
|
||||
|
||||
const acikSatir = kilitliBaslangic ?? rapor.tercihler.length;
|
||||
|
||||
// Kapı ölçümü: paywall yüzeyleri viewport'a girince (oturumda 1'er kez).
|
||||
// Ref'ler yalnızca paketsiz dallarda bağlanır; paketlide DOM'da olmadıkları
|
||||
// için observer hiç kurulmaz.
|
||||
const kilitSatirRef = useGorunumOlayi<HTMLDivElement>(
|
||||
"kilit_goruntulendi",
|
||||
{ yer: "satir" },
|
||||
"satir",
|
||||
);
|
||||
const kilitKartRef = useGorunumOlayi<HTMLElement>(
|
||||
"kilit_goruntulendi",
|
||||
{ yer: "kart" },
|
||||
"kart",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-8 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_360px] xl:grid-cols-[minmax(0,1fr)_400px]">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{/* Harita: listenin coğrafyası — ile dokun, üniversiteleri gör */}
|
||||
<section
|
||||
ref={haritaRef}
|
||||
aria-label="Tercih listesinin Türkiye haritası"
|
||||
className="scroll-mt-24"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-xl">
|
||||
<TercihHaritasi
|
||||
pinler={pinVerisi.pinler}
|
||||
seciliIl={seciliIl}
|
||||
onSeciliIlDegisti={setSeciliIl}
|
||||
onTercihSec={setAcikSira}
|
||||
/>
|
||||
</div>
|
||||
{pinVerisi.haritaDisi > 0 ? (
|
||||
<p className="mt-1 text-center text-xs text-slate-500">
|
||||
{pinVerisi.haritaDisi} tercih harita dışında (KKTC vb.)
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Liste: 24 tercih ve taban verileri. Sayfa başlığı ve revizyon/PDF
|
||||
aksiyonları bu kutunun kendi başlık satırında yaşar. */}
|
||||
<section className={`min-w-0${kilitAcilisi ? " kt-unblur" : ""}`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-1">
|
||||
<h1 className="min-w-0 font-heading text-xl font-bold tracking-tight sm:text-2xl">
|
||||
{hasPaket
|
||||
? "Yapay Zeka tercih listen"
|
||||
: "Veriyi ücretsiz incele. Kararı Yapay Zeka ile netleştir."}
|
||||
</h1>
|
||||
{hasPaket ? (
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Link href="#revizyon">
|
||||
<PencilLine className="size-4" aria-hidden />
|
||||
Revize et
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Link href="/rapor/yazdir" target="_blank">
|
||||
<FileText className="size-4" aria-hidden />
|
||||
PDF
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<RaporListesi
|
||||
rapor={rapor}
|
||||
adaySira={adaySira}
|
||||
acikSira={acikSira}
|
||||
onHaritadaGor={(il) => {
|
||||
if (!il) return;
|
||||
setSeciliIl(il);
|
||||
haritaRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}}
|
||||
kilitliBaslangic={kilitliBaslangic}
|
||||
kilitOverlay={
|
||||
<div
|
||||
ref={kilitSatirRef}
|
||||
className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-700">
|
||||
<Lock className="size-4" aria-hidden />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-heading font-bold text-slate-900">
|
||||
İlk {acikSatir} tercihte Yapay Zeka analizini gördün
|
||||
</p>
|
||||
<p className="mt-1 max-w-xl text-sm text-slate-600">
|
||||
Kalan {rapor.tercihler.length - acikSatir} satırda senin
|
||||
sıralamana özel gerekçe ve risk notu hazır — paketle
|
||||
açılır. Program adları ve taban verileri her zaman
|
||||
ücretsiz.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link href="/paket">
|
||||
Analizleri aç — {paketFiyati} TL
|
||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{hasPaket ? <RevizyonKutusu kalanHak={kalanHak} /> : null}
|
||||
</div>
|
||||
|
||||
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
|
||||
{hasPaket ? (
|
||||
<SohbetClient
|
||||
baslangicKredi={kredi}
|
||||
initialMesajlar={sohbetGecmisi}
|
||||
ozet={ozet}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Sohbet paket kartının üstünde: #sohbet çapası (ve mobildeki
|
||||
"Danışmana sor" butonu) doğrudan danışmana düşsün. */}
|
||||
<SohbetClient
|
||||
baslangicKredi={kredi}
|
||||
initialMesajlar={sohbetGecmisi}
|
||||
ozet={ozet}
|
||||
className="h-[420px]"
|
||||
/>
|
||||
|
||||
<section
|
||||
ref={kilitKartRef}
|
||||
className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"
|
||||
>
|
||||
<div className="border-b border-slate-100 p-6">
|
||||
<h3 className="font-heading text-xl font-bold tracking-tight">
|
||||
Tablo ücretsiz.
|
||||
<br />
|
||||
Karar desteği pakette.
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
Sadece veriyi göstermez; hangi tercihin neden mantıklı
|
||||
olduğunu sıralamana göre açıklar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<ul className="flex flex-col gap-3 text-sm">
|
||||
{[
|
||||
"24 tercihin tamamında kişisel risk analizi",
|
||||
"Her program için kısa, gerekçeli Yapay Zeka yorumu",
|
||||
"2 liste revizyonu ve paylaşılabilir PDF",
|
||||
`${paketKredi} Yapay Zeka danışman sorusu`,
|
||||
].map((ozellik) => (
|
||||
<li key={ozellik} className="flex items-start gap-2.5">
|
||||
<Check
|
||||
className="mt-0.5 size-4 shrink-0 text-emerald-600"
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{ozellik}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="text-xs text-slate-500">Tek seferlik</p>
|
||||
<p className="font-heading text-3xl font-bold">
|
||||
{paketFiyati} TL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-5 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">
|
||||
Kişisel analizi aç
|
||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="mt-4 flex items-center justify-center gap-4 text-[11px] text-slate-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ShieldCheck className="size-3.5" aria-hidden />
|
||||
iyzico güvenli ödeme
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Bot className="size-3.5" aria-hidden />
|
||||
Gerçek YÖK verisi
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Check,
|
||||
FileText,
|
||||
Lock,
|
||||
MessageCircleQuestion,
|
||||
PencilLine,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { MessageCircleQuestion, Sparkles } from "lucide-react";
|
||||
import { verifySession, getCurrentUser } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { PUAN_TURLERI } from "@/lib/db";
|
||||
import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RaporListesi } from "@/components/rapor-listesi";
|
||||
import { ViewportPortal } from "@/components/viewport-portal";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
|
||||
import { SohbetClient, type Mesaj } from "./sohbet-client";
|
||||
import { RevizyonKutusu } from "./revizyon-kutusu";
|
||||
import { raporMaskele, ACIK_SATIR, type MaskeliRapor } from "@/lib/rapor-maske";
|
||||
import type { Mesaj } from "./sohbet-client";
|
||||
import { ListemGovde } from "./listem-govde";
|
||||
import { ListemBosCta } from "./listem-bos-cta";
|
||||
import { ListeUretici } from "./liste-uretici";
|
||||
|
||||
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort
|
||||
// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA
|
||||
@@ -46,9 +32,13 @@ const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
||||
export default async function ListemPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ acildi?: string }>;
|
||||
searchParams: Promise<{ acildi?: string; uret?: string }>;
|
||||
}) {
|
||||
const session = await verifySession("/listem");
|
||||
// Girişsiz gelene callback'i ?uret=1 ile ver: girişten dönüşte bekleyen
|
||||
// seçimlerle üretim kaldığı yerden başlasın.
|
||||
const ilkParams = await searchParams;
|
||||
const geriYol = ilkParams.uret === "1" ? "/listem?uret=1" : "/listem";
|
||||
const session = await verifySession(geriYol);
|
||||
const [user, params, satir, sohbetGecmisi] = await Promise.all([
|
||||
getCurrentUser(),
|
||||
searchParams,
|
||||
@@ -66,46 +56,60 @@ export default async function ListemPage({
|
||||
.orderBy(asc(schema.chatMessages.createdAt))
|
||||
.limit(100),
|
||||
]);
|
||||
if (!user) redirect("/giris?callback=/listem");
|
||||
if (!user) redirect(`/giris?callback=${encodeURIComponent(geriYol)}`);
|
||||
|
||||
const { acildi } = params;
|
||||
const kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
|
||||
const raporVar = Boolean(satir?.result);
|
||||
|
||||
if (!raporVar) {
|
||||
const bosDurum = (
|
||||
<section className="mx-auto flex max-w-2xl flex-col items-center px-4 py-16 text-center">
|
||||
<Sparkles className="size-10 text-primary" aria-hidden />
|
||||
<h1 className="mt-4 font-heading text-2xl font-bold">
|
||||
Henüz Yapay Zeka listen yok
|
||||
</h1>
|
||||
<ListemBosCta />
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-4xl px-4 py-12">
|
||||
<section className="mx-auto flex max-w-2xl flex-col items-center px-4 py-16 text-center">
|
||||
<Sparkles className="size-10 text-primary" aria-hidden />
|
||||
<h1 className="mt-4 font-heading text-2xl font-bold">
|
||||
Henüz Yapay Zeka listen yok
|
||||
</h1>
|
||||
<ListemBosCta />
|
||||
</section>
|
||||
{/* ?uret=1: sihirbazdan yönlendirildi — bekleyen seçimlerle üretim
|
||||
burada koşar, animasyon ve hata/kredi durumları burada yaşar. */}
|
||||
{params.uret === "1" ? (
|
||||
<ListeUretici
|
||||
hasPaket={Boolean(user.hasPaket)}
|
||||
bosDurum={bosDurum}
|
||||
/>
|
||||
) : (
|
||||
bosDurum
|
||||
)}
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const rapor = user.hasPaket
|
||||
const rapor: MaskeliRapor = user.hasPaket
|
||||
? (satir!.result as RaporSonuc)
|
||||
: raporMaskele(satir!.result as RaporSonuc);
|
||||
const raporParams = satir!.params as RaporParams;
|
||||
// Danışman özeti sohbetin açılış mesajı olarak gösterilir; sohbet
|
||||
// /api/soru üzerinden zaten liste + seçim bağlamıyla çalışıyor.
|
||||
// Paketsizde rapor zaten maskeli: 1 uyarı açık, kalanının yalnızca sayısı var.
|
||||
const danismanOzeti = rapor.genelDegerlendirme
|
||||
? {
|
||||
metin: rapor.genelDegerlendirme,
|
||||
uyarilar: user.hasPaket ? rapor.uyarilar : undefined,
|
||||
uyarilar: rapor.uyarilar,
|
||||
kilitli: !user.hasPaket,
|
||||
kilitliUyariSayisi: user.hasPaket ? undefined : rapor.kilitliUyariSayisi,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-4xl px-4 py-12">
|
||||
<main className="mx-auto w-full max-w-7xl px-4 py-12">
|
||||
{kilitAcilisi ? (
|
||||
<div
|
||||
role="status"
|
||||
@@ -116,184 +120,21 @@ export default async function ListemPage({
|
||||
artık açık.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
{user.hasPaket ? (
|
||||
<SectionEyebrow className="mb-3">Paketin aktif</SectionEyebrow>
|
||||
) : null}
|
||||
<h1 className="max-w-2xl font-heading text-xl font-bold tracking-tight sm:text-2xl">
|
||||
{user.hasPaket
|
||||
? "Yapay Zeka tercih listen"
|
||||
: "Veriyi ücretsiz incele. Kararı Yapay Zeka ile netleştir."}
|
||||
</h1>
|
||||
{user.hasPaket ? (
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
|
||||
{raporParams.sira.toLocaleString("tr-TR")}. sıra ·{" "}
|
||||
{PUAN_TURLERI[raporParams.tur as keyof typeof PUAN_TURLERI] ??
|
||||
raporParams.tur}{" "}
|
||||
· 24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle
|
||||
aşağıda inceleyebilirsin.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{user.hasPaket ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Link href="#revizyon">
|
||||
<PencilLine className="size-4" aria-hidden />
|
||||
Revize et
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Link href="/rapor/yazdir" target="_blank">
|
||||
<FileText className="size-4" aria-hidden />
|
||||
PDF
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<section
|
||||
className={`min-w-0 rounded-2xl border border-slate-200 bg-slate-50/60 p-3 sm:p-5${
|
||||
kilitAcilisi ? " kt-unblur" : ""
|
||||
}`}
|
||||
>
|
||||
<RaporListesi
|
||||
rapor={rapor}
|
||||
adaySira={raporParams.sira}
|
||||
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
|
||||
kilitOverlay={
|
||||
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-700">
|
||||
<Lock className="size-4" aria-hidden />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-heading font-bold text-slate-900">
|
||||
İlk {ACIK_SATIR} tercihte Yapay Zeka analizini gördün
|
||||
</p>
|
||||
<p className="mt-1 max-w-xl text-sm text-slate-600">
|
||||
Kalan {24 - ACIK_SATIR} tercihin kişisel risk, gerekçe ve
|
||||
Yapay Zeka yorumu paketle açılır. Program adları ve taban
|
||||
verileri her zaman ücretsiz.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link href="/paket">
|
||||
Analizleri aç — {PAKET_FIYATI} TL
|
||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{user.hasPaket ? (
|
||||
<RevizyonKutusu
|
||||
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
|
||||
{user.hasPaket ? (
|
||||
<SohbetClient
|
||||
baslangicKredi={user.creditBalance}
|
||||
initialMesajlar={sohbetGecmisi as Mesaj[]}
|
||||
ozet={danismanOzeti}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="border-b border-slate-100 p-6">
|
||||
<h3 className="font-heading text-xl font-bold tracking-tight">
|
||||
Tablo ücretsiz.
|
||||
<br />
|
||||
Karar desteği pakette.
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
Sadece veriyi göstermez; hangi tercihin neden mantıklı
|
||||
olduğunu sıralamana göre açıklar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<ul className="flex flex-col gap-3 text-sm">
|
||||
{[
|
||||
"24 tercihin tamamında kişisel risk analizi",
|
||||
"Her program için kısa, gerekçeli Yapay Zeka yorumu",
|
||||
"2 liste revizyonu ve paylaşılabilir PDF",
|
||||
`${URUNLER.paket.credits} Yapay Zeka danışman sorusu`,
|
||||
].map((ozellik) => (
|
||||
<li key={ozellik} className="flex items-start gap-2.5">
|
||||
<Check
|
||||
className="mt-0.5 size-4 shrink-0 text-emerald-600"
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{ozellik}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="text-xs text-slate-500">Tek seferlik</p>
|
||||
<p className="font-heading text-3xl font-bold">
|
||||
{PAKET_FIYATI} TL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-5 h-12 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">
|
||||
Kişisel analizi aç
|
||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="mt-4 flex items-center justify-center gap-4 text-[11px] text-slate-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ShieldCheck className="size-3.5" aria-hidden />
|
||||
iyzico güvenli ödeme
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Bot className="size-3.5" aria-hidden />
|
||||
Gerçek YÖK verisi
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SohbetClient
|
||||
baslangicKredi={user.creditBalance}
|
||||
initialMesajlar={sohbetGecmisi as Mesaj[]}
|
||||
ozet={danismanOzeti}
|
||||
className="h-[520px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında
|
||||
(bkz. listem-govde.tsx) — sayfa üstünde ayrı bir header yok. */}
|
||||
<ListemGovde
|
||||
rapor={rapor}
|
||||
adaySira={raporParams.sira}
|
||||
hasPaket={Boolean(user.hasPaket)}
|
||||
kilitAcilisi={kilitAcilisi}
|
||||
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
|
||||
kredi={user.creditBalance}
|
||||
sohbetGecmisi={sohbetGecmisi as Mesaj[]}
|
||||
ozet={danismanOzeti}
|
||||
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
|
||||
paketFiyati={PAKET_FIYATI}
|
||||
paketKredi={URUNLER.paket.credits}
|
||||
/>
|
||||
|
||||
<ViewportPortal>
|
||||
<a
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AlertTriangle, ArrowUp, Bot, Coins, Lock, Sparkles } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { AlertTriangle, ArrowUp, Coins, Lock, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -35,6 +36,26 @@ export interface DanismanOzeti {
|
||||
metin: string;
|
||||
uyarilar?: string[];
|
||||
kilitli?: boolean;
|
||||
/** Paketsizde açık uyarının ardından kilitli kalan uyarı sayısı. */
|
||||
kilitliUyariSayisi?: number;
|
||||
}
|
||||
|
||||
/** Danışman mesajlarının avatarı — KolayTercih markası. */
|
||||
function DanismanAvatar() {
|
||||
return (
|
||||
<span
|
||||
className="mt-0.5 flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-slate-200 bg-white"
|
||||
aria-hidden
|
||||
>
|
||||
<Image
|
||||
src="/kolay-tercih-mark.svg"
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-5"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const ORNEK_SORULAR = [
|
||||
@@ -157,7 +178,7 @@ export function SohbetClient({
|
||||
aria-label="Danışman sohbeti"
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white",
|
||||
className ?? "h-[calc(100dvh-9rem)] min-h-[480px]",
|
||||
className ?? "h-[calc(100dvh-16rem)] min-h-[400px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
@@ -177,9 +198,9 @@ export function SohbetClient({
|
||||
<ChatContainerContent className="gap-4 p-4">
|
||||
{ozet ? (
|
||||
<Message className="justify-start">
|
||||
<DanismanAvatar />
|
||||
<div className="flex max-w-[85%] flex-col gap-2">
|
||||
<p className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
|
||||
<Bot className="size-3.5 text-primary" aria-hidden />
|
||||
<p className="text-xs font-semibold text-slate-500">
|
||||
Danışman özeti
|
||||
</p>
|
||||
<MessageContent className="bg-slate-100 text-slate-800">
|
||||
@@ -204,8 +225,17 @@ export function SohbetClient({
|
||||
{ozet.kilitli ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
Değerlendirmenin tamamı ve sıralamana özel uyarılar
|
||||
pakette.
|
||||
{ozet.kilitliUyariSayisi ? (
|
||||
<>
|
||||
{ozet.kilitliUyariSayisi} uyarı daha kilitli —
|
||||
sıralamana özel uyarıların pakette.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Değerlendirmenin tamamı ve sıralamana özel uyarılar
|
||||
pakette.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -281,6 +311,7 @@ export function SohbetClient({
|
||||
</Message>
|
||||
) : (
|
||||
<Message key={m.id} className="justify-start">
|
||||
<DanismanAvatar />
|
||||
{m.content ? (
|
||||
<MessageContent
|
||||
markdown
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
|
||||
import {
|
||||
spendCredits,
|
||||
grantCredits,
|
||||
krediBittiIsaretle,
|
||||
RAPOR_KREDI,
|
||||
MAX_REVIZYON,
|
||||
} from "@/lib/credits";
|
||||
@@ -113,6 +114,7 @@ export async function listeOlustur(input: {
|
||||
});
|
||||
if (!harcama.ok) {
|
||||
if (harcama.error === "INSUFFICIENT") {
|
||||
await krediBittiIsaretle(user.id);
|
||||
return {
|
||||
ok: false,
|
||||
code: "KREDI",
|
||||
@@ -241,6 +243,7 @@ export async function listeRevize(input: {
|
||||
});
|
||||
if (!harcama.ok) {
|
||||
if (harcama.error === "INSUFFICIENT") {
|
||||
await krediBittiIsaretle(user.id);
|
||||
return {
|
||||
ok: false,
|
||||
code: "KREDI",
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, FileText, Lock, RefreshCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SohbetPopup } from "./sohbet-popup";
|
||||
import { RaporListesi } from "@/components/rapor-listesi";
|
||||
import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { ACIK_SATIR } from "@/lib/rapor-maske";
|
||||
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
|
||||
import { dilimdenRisk, riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
||||
|
||||
const MAX_REVIZYON = 2;
|
||||
|
||||
export type MevcutRapor = {
|
||||
rapor: RaporSonuc;
|
||||
params: RaporParams;
|
||||
revisionCount: number;
|
||||
};
|
||||
|
||||
/** Panelin liste dışındaki geçici durumları (liste, rapor varlığından türer). */
|
||||
export type PanelDurum = "bos" | "uretiliyor" | "kredi-yetersiz";
|
||||
|
||||
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
|
||||
guvenli: 0,
|
||||
"az-riskli": 1,
|
||||
riskli: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Rapordan harita pinleri türetir. Açık satırlar üniversite bazında gruplanır
|
||||
* (kampüs konumunda, adıyla); kilitli satırlar il başına tek adsız "?" pinine
|
||||
* toplanır — maskeli veride üniversite adı zaten client'a inmediği için
|
||||
* harita paywall'ı delemez. Haritada karşılığı olmayan tercihler sayılır.
|
||||
*/
|
||||
function pinleriTuret(
|
||||
rapor: RaporSonuc,
|
||||
adaySira: number,
|
||||
kilitliBaslangic: number,
|
||||
): { pinler: HaritaPin[]; haritaDisi: number } {
|
||||
const gruplar = new Map<string, HaritaPin>();
|
||||
let haritaDisi = 0;
|
||||
|
||||
rapor.tercihler.forEach((t, i) => {
|
||||
const p = rapor.programlar[t.programId];
|
||||
if (!p) return;
|
||||
const risk = riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim);
|
||||
const kilitli = i >= kilitliBaslangic;
|
||||
|
||||
if (kilitli) {
|
||||
const konum = ilKonum(p.il);
|
||||
if (!konum) {
|
||||
haritaDisi += 1;
|
||||
return;
|
||||
}
|
||||
const key = `kilit|${p.il}`;
|
||||
const mevcut = gruplar.get(key);
|
||||
if (mevcut) {
|
||||
mevcut.tercihSiralari.push(t.sira);
|
||||
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
|
||||
} else {
|
||||
// Açık pinlerle üst üste binmesin diye il merkezinin hafif altına
|
||||
gruplar.set(key, {
|
||||
id: key,
|
||||
universite: "",
|
||||
il: p.il,
|
||||
x: konum.x,
|
||||
y: konum.y + 9,
|
||||
tercihSiralari: [t.sira],
|
||||
risk,
|
||||
kilitli: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const konum = uniKonum(p.universite, p.il);
|
||||
if (!konum) {
|
||||
haritaDisi += 1;
|
||||
return;
|
||||
}
|
||||
const key = `${uniAdiNormalize(p.universite)}|${p.il ?? ""}`;
|
||||
const mevcut = gruplar.get(key);
|
||||
if (mevcut) {
|
||||
mevcut.tercihSiralari.push(t.sira);
|
||||
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
|
||||
} else {
|
||||
gruplar.set(key, {
|
||||
id: key,
|
||||
universite: p.universite,
|
||||
il: p.il,
|
||||
x: konum.x,
|
||||
y: konum.y,
|
||||
tercihSiralari: [t.sira],
|
||||
risk,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { pinler: [...gruplar.values()], haritaDisi };
|
||||
}
|
||||
|
||||
const URETIM_MESAJLARI = [
|
||||
"Sıralamana uygun programlar taranıyor…",
|
||||
"Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…",
|
||||
"Her tercih için risk ve trend analizi yazılıyor…",
|
||||
"Son rötuşlar yapılıyor, neredeyse hazır…",
|
||||
];
|
||||
|
||||
/**
|
||||
* Sonuç sayfası gövdesindeki liste paneli: üretim animasyonu, paketli tam
|
||||
* liste veya paketsiz progressive-blur önizleme burada render edilir.
|
||||
* (Sihirbaz modalı yalnızca adımları toplar; sonuç artık sayfanın kendisinde.)
|
||||
*/
|
||||
export function ListePaneli({
|
||||
durum,
|
||||
rapor,
|
||||
hasPaket,
|
||||
kredi,
|
||||
sira,
|
||||
acilis,
|
||||
onRevize,
|
||||
}: {
|
||||
durum: PanelDurum;
|
||||
rapor: MevcutRapor | null;
|
||||
hasPaket: boolean;
|
||||
kredi: number;
|
||||
sira: number;
|
||||
/** Ödeme dönüşü: blur çözülme animasyonuyla aç */
|
||||
acilis?: boolean;
|
||||
onRevize: (feedback: string) => Promise<boolean>;
|
||||
}) {
|
||||
const kilitli = !hasPaket;
|
||||
|
||||
// Harita ⇄ liste köprüsü: pinden satıra, satırdan haritaya
|
||||
const [seciliIl, setSeciliIl] = useState<string | null>(null);
|
||||
const [acikSira, setAcikSira] = useState<number | null>(null);
|
||||
const haritaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const pinVerisi = useMemo(
|
||||
() =>
|
||||
rapor
|
||||
? pinleriTuret(
|
||||
rapor.rapor,
|
||||
rapor.params.sira,
|
||||
kilitli ? ACIK_SATIR : rapor.rapor.tercihler.length,
|
||||
)
|
||||
: null,
|
||||
[rapor, kilitli],
|
||||
);
|
||||
|
||||
if (durum === "uretiliyor") return <UretimGovdesi />;
|
||||
|
||||
if (durum === "kredi-yetersiz") {
|
||||
// Paketli kullanıcıya paket satma: onun ürünü top-up. Liste ve sohbet
|
||||
// geçmişi kalıcı; kredi yükleyince kaldığı yerden devam eder.
|
||||
return (
|
||||
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||
<Lock className="mx-auto size-8 text-slate-400" aria-hidden />
|
||||
<h2 className="mt-4 font-heading text-xl font-bold">
|
||||
Kredin bu liste için yetmiyor
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
|
||||
{hasPaket
|
||||
? "Liste üretimi 3 kredi. +30 kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin."
|
||||
: "Liste üretimi 3 kredi. Tercih Dönemi Paketi'yle 60 kredi, tam liste, PDF ve revizyon hakları birlikte gelir."}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">
|
||||
{hasPaket ? "+30 kredi yükle — 129 TL" : "Paketi aktive et — 299 TL"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!rapor) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`mt-16 ${acilis && !kilitli ? "kt-unblur" : ""}`}
|
||||
aria-label="Tercih listen"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-bold">
|
||||
24 Tercihlik Listen
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{sira.toLocaleString("tr-TR")}. sıra ·{" "}
|
||||
{kilitli
|
||||
? `tüm taban verileri açık · ${ACIK_SATIR} Yapay Zeka analiz örneği`
|
||||
: `${kredi} kredin kaldı`}
|
||||
</p>
|
||||
</div>
|
||||
{!kilitli ? (
|
||||
<Button asChild variant="outline" className="cursor-pointer">
|
||||
<Link href="/rapor/yazdir" target="_blank">
|
||||
<FileText className="size-4" aria-hidden />
|
||||
PDF indir
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Harita: 24 tercihin coğrafyası — ile dokun, üniversiteleri gör */}
|
||||
<div ref={haritaRef} className="mt-5 scroll-mt-24">
|
||||
<TercihHaritasi
|
||||
pinler={pinVerisi?.pinler ?? []}
|
||||
seciliIl={seciliIl}
|
||||
onSeciliIlDegisti={setSeciliIl}
|
||||
onTercihSec={setAcikSira}
|
||||
/>
|
||||
{pinVerisi && pinVerisi.haritaDisi > 0 ? (
|
||||
<p className="mt-1 px-1 text-xs text-slate-500">
|
||||
{pinVerisi.haritaDisi} tercih harita dışında (KKTC vb.)
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<RaporListesi
|
||||
rapor={rapor.rapor}
|
||||
adaySira={rapor.params.sira}
|
||||
acikSira={acikSira}
|
||||
onHaritadaGor={(il) => {
|
||||
if (!il) return;
|
||||
setSeciliIl(il);
|
||||
haritaRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}}
|
||||
kilitliBaslangic={kilitli ? ACIK_SATIR : undefined}
|
||||
kilitOverlay={
|
||||
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex gap-3">
|
||||
<Lock
|
||||
className="mt-1 size-5 shrink-0 text-orange-700"
|
||||
aria-hidden
|
||||
/>
|
||||
<div>
|
||||
<p className="font-heading font-bold text-slate-900">
|
||||
Tüm program ve taban verileri ücretsiz
|
||||
</p>
|
||||
<p className="mt-1 max-w-xl text-sm text-slate-600">
|
||||
Paket; kalan {24 - ACIK_SATIR} tercih için kişisel risk,
|
||||
gerekçe, PDF, 2 revizyon ve 60 Yapay Zeka danışman sorusunu açar.
|
||||
</p>
|
||||
{kredi > 0 ? (
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Kalan {kredi} deneme kredin hesabında duruyor.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-12 w-full shrink-0 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link href="/paket">299 TL — Analizleri aç</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!kilitli ? (
|
||||
<div className="mt-6">
|
||||
<RevizyonKutusu
|
||||
kalanHak={MAX_REVIZYON - rapor.revisionCount}
|
||||
onRevize={onRevize}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Yapay Zeka danışman: sağ altta yüzen buton, alttan popup olarak açılır */}
|
||||
<SohbetPopup kredi={kredi} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UretimGovdesi() {
|
||||
const [mesajIdx, setMesajIdx] = useState(0);
|
||||
const govdeRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
|
||||
}, 7000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Modal geçiş anında unmount edildiğinden Radix odağı tetikleyiciye geri
|
||||
// veremiyor; odağı panele alıp klavye ve ekran okuyucu akışını sürdürüyoruz.
|
||||
useEffect(() => {
|
||||
govdeRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={govdeRef}
|
||||
tabIndex={-1}
|
||||
aria-live="polite"
|
||||
className="mt-16 rounded-2xl border border-slate-200 bg-white p-6 outline-none sm:p-8"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<p className="font-heading text-lg font-bold">Listen hazırlanıyor</p>
|
||||
<p className="max-w-sm text-sm text-slate-500">
|
||||
{URETIM_MESAJLARI[mesajIdx]}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gelmekte olan tercih listesinin iskeleti */}
|
||||
<div className="mt-8 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-44" />
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RevizyonKutusu({
|
||||
kalanHak,
|
||||
onRevize,
|
||||
}: {
|
||||
kalanHak: number;
|
||||
onRevize: (feedback: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
if (kalanHak <= 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p className="text-sm text-slate-500">
|
||||
Revizyon hakların doldu. Aşağıdan listen hakkında soru sorabilirsin.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h3 className="font-heading text-base font-bold">
|
||||
Listede değişiklik mi istiyorsun?
|
||||
</h3>
|
||||
<p className="mb-3 mt-1 text-sm text-slate-600">
|
||||
Ne değişsin istediğini yaz; liste aynı gerçek veriyle yeniden kurulsun
|
||||
(3 kredi).
|
||||
</p>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={1000}
|
||||
placeholder="ör. İlk 5 tercihte daha fazla İstanbul olsun, garanti dilimini genişlet…"
|
||||
disabled={pending}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
/>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={pending || feedback.trim().length < 5}
|
||||
onClick={async () => {
|
||||
setPending(true);
|
||||
const ok = await onRevize(feedback.trim());
|
||||
setPending(false);
|
||||
if (ok) setFeedback("");
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<RefreshCcw className="size-4" aria-hidden />
|
||||
{pending ? "Yeniden kuruluyor…" : "Revize et (3 kredi)"}
|
||||
</Button>
|
||||
<span className="text-xs text-slate-500">
|
||||
Kalan hak: <span className="font-semibold">{kalanHak}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,23 +6,16 @@ import {
|
||||
searchByRank,
|
||||
type PuanTuruKey,
|
||||
} from "@/lib/db";
|
||||
import { getSession, getCurrentUser } from "@/lib/session";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { raporMaskele } from "@/lib/rapor-maske";
|
||||
import { tadimlikSec } from "@/lib/tadimlik-havuzu";
|
||||
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
|
||||
import type { MevcutRapor } from "./liste-paneli";
|
||||
import { TadimlikSatiri } from "./tadimlik-satiri";
|
||||
import { ProgramTablosu } from "@/components/program-tablosu";
|
||||
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SiraGerekli } from "./sira-gerekli";
|
||||
|
||||
// listeOlustur aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort
|
||||
// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA
|
||||
// dolmalı ki kredi iadesi her zaman çalışabilsin.
|
||||
export const maxDuration = 120;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Listeni Kur",
|
||||
// Param uzayı sonsuz + içerik sıralamaya göre kişisel: indexlenmez ama
|
||||
@@ -38,7 +31,6 @@ export default async function SonucPage({
|
||||
sira?: string;
|
||||
tur?: string;
|
||||
sihirbaz?: string;
|
||||
acildi?: string;
|
||||
hazir?: string;
|
||||
}>;
|
||||
}) {
|
||||
@@ -58,34 +50,25 @@ export default async function SonucPage({
|
||||
);
|
||||
}
|
||||
|
||||
// Sihirbaz için: kullanıcı durumu, kayıtlı rapor ve dinamik facet'ler.
|
||||
// Rapor sorgusu yalnızca session.user.id'ye bağlı — getCurrentUser'ı
|
||||
// beklemeden paralel çalışır (seri DB zinciri kurma).
|
||||
// Sihirbaz için: kullanıcının kayıtlı raporu var mı? Listenin kendisi
|
||||
// /listem'de yaşar; burada yalnızca CTA'nın hangi hâlde render edileceği
|
||||
// için varlık bilgisi gerekir.
|
||||
const session = await getSession();
|
||||
const [user, raporSatiri] = session
|
||||
? await Promise.all([
|
||||
getCurrentUser(),
|
||||
appDb.query.reports.findFirst({
|
||||
where: eq(schema.reports.userId, session.user.id),
|
||||
}),
|
||||
])
|
||||
: [null, null];
|
||||
const raporSatiri = session
|
||||
? await appDb.query.reports.findFirst({
|
||||
where: eq(schema.reports.userId, session.user.id),
|
||||
columns: { result: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
const facetler = rankWindowFacets(sira, turKey);
|
||||
// Yapay Zeka'sız ham liste: yalnızca sıralamayla ulaşılabilen programlar (dilimli)
|
||||
const hamSonuclar = searchByRank(sira, turKey, { limitPerBucket: 20 });
|
||||
// Paketsize giden rapor sunucuda maskelenir: blur yalnızca CSS olduğundan
|
||||
// kilitli satırların gerçek içeriği client'a hiç inmez.
|
||||
const hasPaket = Boolean(user?.hasPaket);
|
||||
const mevcutRapor: MevcutRapor | null = raporSatiri?.result
|
||||
? {
|
||||
rapor: hasPaket
|
||||
? (raporSatiri.result as RaporSonuc)
|
||||
: raporMaskele(raporSatiri.result as RaporSonuc),
|
||||
params: raporSatiri.params as RaporParams,
|
||||
revisionCount: raporSatiri.revisionCount,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Girişsiz tadımlık (İP-B): SSR'da kovaya uyan "genel" satır seçilir;
|
||||
// sihirbaz kategorisi client'ta okunup /api/tadimlik ile kişiselleştirilir.
|
||||
// Girişli kullanıcı gerçek ürünü zaten görebildiği için tadımlık almaz.
|
||||
const tadimlik = session ? null : await tadimlikSec(sira, turKey);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
@@ -99,18 +82,21 @@ export default async function SonucPage({
|
||||
gövdesi; puan türü navbar'daki sıralama rozetinden değiştirilir */}
|
||||
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
|
||||
|
||||
{/* Sihirbaz akışı (CTA + modal + Yapay Zeka listesi) — blurlu Yapay Zeka paneli
|
||||
sayfanın en altında yaşar */}
|
||||
{/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
|
||||
Havuzda içerik yoksa bileşen hiç render edilmez. */}
|
||||
{!session ? (
|
||||
<TadimlikSatiri sira={sira} tur={turKey} ilk={tadimlik} />
|
||||
) : null}
|
||||
|
||||
{/* Sihirbaz akışı (CTA + modal) — üretim ve liste /listem'de yaşar,
|
||||
burada yalnızca funnel kartları render edilir */}
|
||||
<SihirbazBolumu
|
||||
sira={sira}
|
||||
tur={turKey}
|
||||
facetler={facetler}
|
||||
girisliMi={Boolean(session)}
|
||||
hasPaket={hasPaket}
|
||||
kredi={user?.creditBalance ?? 0}
|
||||
mevcutRapor={mevcutRapor}
|
||||
raporVar={Boolean(raporSatiri?.result)}
|
||||
otomatikAc={params.sihirbaz === "1"}
|
||||
acilis={params.acildi === "1"}
|
||||
secimlerHazir={params.hazir === "1"}
|
||||
/>
|
||||
|
||||
|
||||
@@ -3,29 +3,26 @@
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, X } from "lucide-react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { animate } from "motion";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ViewportPortal } from "@/components/viewport-portal";
|
||||
import { PixelField } from "@/components/pixel-decor";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import type { SihirbazFacetleri } from "@/lib/db";
|
||||
import {
|
||||
SIHIRBAZ_AC_EVENT,
|
||||
SIHIRBAZ_STORAGE_KEY,
|
||||
sihirbazDogrula,
|
||||
sonucHref,
|
||||
tercihProfiliSil,
|
||||
tercihProfiliYaz,
|
||||
type SihirbazSecimleri,
|
||||
} from "@/lib/sihirbaz";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { SihirbazModal } from "./sihirbaz-modal";
|
||||
import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli";
|
||||
import { listeOlustur, listeRevize } from "./actions";
|
||||
|
||||
const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali";
|
||||
// Üretim /listem'de koşar (bkz. listem/liste-uretici.tsx); sihirbaz
|
||||
// tamamlanınca seçimler storage'a yazılıp kullanıcı buraya yönlendirilir.
|
||||
const LISTEM_URET_HREF = "/listem?uret=1";
|
||||
|
||||
// SSR'da false, client'ta true — storage'a ancak mount sonrası dokunuruz
|
||||
const bosAbone = () => () => {};
|
||||
@@ -43,42 +40,35 @@ type BekleyenSecim = {
|
||||
|
||||
/**
|
||||
* Sonuç sayfasında sihirbaz akışını yöneten client orkestratör:
|
||||
* - Sayfa ortasındaki büyük CTA + sabit alt çağrı kartı (rapor yokken)
|
||||
* - Sayfa ortasındaki büyük CTA (rapor yokken)
|
||||
* - Adımları toplayan modal
|
||||
* - Üretim/önizleme/liste paneli (sayfa gövdesinde)
|
||||
* Akış: adımlar → kredi ile üretim → paketsizse progressive-blur önizleme,
|
||||
* paketliyse tam liste. Girişsiz kullanıcı hero'dan seçimleri tamamlanmış
|
||||
* gelirse önce ücretsiz tabloyu görür; giriş CTA'sı "hazır seçimlerin
|
||||
* kaydedilmesi" olarak panelde sunulur, girişten dönüşte üretim otomatik
|
||||
* başlar. Modal içinden ilerleyen girişsiz kullanıcı ise butonun söylediği
|
||||
* gibi doğrudan girişe gider.
|
||||
* Liste bu sayfada üretilmez ve gösterilmez: sihirbaz tamamlanınca seçimler
|
||||
* storage'a yazılır ve kullanıcı /listem'e yönlendirilir; üretim animasyonu,
|
||||
* kredi/hata durumları ve listenin kendisi orada yaşar. Girişsiz kullanıcı
|
||||
* hero'dan seçimleri tamamlanmış gelirse önce ücretsiz tabloyu görür; giriş
|
||||
* CTA'sı "hazır seçimlerin kaydedilmesi" olarak panelde sunulur, girişten
|
||||
* dönüş doğrudan /listem'e düşer ve üretim orada başlar.
|
||||
*/
|
||||
export function SihirbazBolumu({
|
||||
sira,
|
||||
tur,
|
||||
facetler,
|
||||
girisliMi,
|
||||
hasPaket,
|
||||
kredi: baslangicKredi,
|
||||
mevcutRapor,
|
||||
raporVar,
|
||||
otomatikAc,
|
||||
acilis,
|
||||
secimlerHazir,
|
||||
}: {
|
||||
sira: number;
|
||||
tur: string;
|
||||
facetler: SihirbazFacetleri;
|
||||
girisliMi: boolean;
|
||||
hasPaket: boolean;
|
||||
kredi: number;
|
||||
mevcutRapor: MevcutRapor | null;
|
||||
/** Kullanıcının kayıtlı bir Yapay Zeka listesi var mı (listenin kendisi /listem'de) */
|
||||
raporVar: boolean;
|
||||
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
|
||||
otomatikAc?: boolean;
|
||||
/** ?acildi=1 (ödeme dönüşü): blur çözülme animasyonuyla aç */
|
||||
acilis?: boolean;
|
||||
/** ?hazir=1 (hero sihirbazından girişsiz geliş): seçimler storage'da,
|
||||
panel giriş CTA'sına döner. URL'den gelir ki SSR'da doğru panel
|
||||
render edilsin — mount sonrası anahtar değişimi animasyonu donduruyor. */
|
||||
render edilsin. */
|
||||
secimlerHazir?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@@ -87,36 +77,19 @@ export function SihirbazBolumu({
|
||||
|
||||
const [elleAcik, setElleAcik] = useState(false);
|
||||
const [elleKapandi, setElleKapandi] = useState(false);
|
||||
const [kartGizle, setKartGizle] = useState(false);
|
||||
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
|
||||
// ?sihirbaz=1 fallback modalı storage okunmadan açılmasın: hero'dan gelen
|
||||
// kayıtlı seçimler varken modal bir anlığına açılıp kapanıyordu.
|
||||
const [depoOkundu, setDepoOkundu] = useState(false);
|
||||
|
||||
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
|
||||
const [kredi, setKredi] = useState(baslangicKredi);
|
||||
const [paketli, setPaketli] = useState(hasPaket);
|
||||
const [durum, setDurum] = useState<PanelDurum>("bos");
|
||||
|
||||
// Adımlarda yapılan son seçimler (hata sonrası modala dönüşte prefill)
|
||||
const [sonSecimler, setSonSecimler] = useState<SihirbazSecimleri | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
|
||||
const requestIdRef = useRef<string | null>(null);
|
||||
// Revizyon için aynı desen (bkz. listem/revizyon-kutusu.tsx)
|
||||
const revizyonIdRef = useRef<string | null>(null);
|
||||
const otomatikBasladi = useRef(false);
|
||||
const bolumRef = useRef<HTMLDivElement>(null);
|
||||
// Funnel CTA'ları (banner olayı dahil) tıklama anındaki bekleyen seçimi
|
||||
// görsün diye ref'te tutulur; olay dinleyicisi bir kez bağlanır.
|
||||
const bekleyenRef = useRef<BekleyenSecim | null>(null);
|
||||
|
||||
// Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku
|
||||
// Mount sonrası storage'dan bekleyen seçimleri oku
|
||||
useEffect(() => {
|
||||
try {
|
||||
const kaliciKapali = Boolean(sessionStorage.getItem(KAPATMA_KEY));
|
||||
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
|
||||
let yeniBekleyen: BekleyenSecim | null = null;
|
||||
if (ham) {
|
||||
@@ -133,9 +106,8 @@ export function SihirbazBolumu({
|
||||
};
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (kaliciKapali) setKartGizle(true);
|
||||
if (yeniBekleyen) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setBekleyen(yeniBekleyen);
|
||||
bekleyenRef.current = yeniBekleyen;
|
||||
}
|
||||
@@ -151,19 +123,6 @@ export function SihirbazBolumu({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Bölüm ekran dışındaysa (kullanıcı sihirbazı üstteki banner'dan ya da
|
||||
* sabit alt karttan açmış olabilir) modal hâlâ perdeyi kaplarken kaydırırız;
|
||||
* böylece Motion ile giren hedef panel görünür alanda kalır.
|
||||
*/
|
||||
function bolumuGorunureAl() {
|
||||
const el = bolumRef.current;
|
||||
if (!el) return;
|
||||
const kutu = el.getBoundingClientRect();
|
||||
if (kutu.top >= 0 && kutu.bottom <= window.innerHeight) return;
|
||||
el.scrollIntoView({ block: "center", behavior: "instant" });
|
||||
}
|
||||
|
||||
async function modalCikisiniOynat() {
|
||||
if (hareketiAzalt) return;
|
||||
|
||||
@@ -202,85 +161,26 @@ export function SihirbazBolumu({
|
||||
await modalAnimasyonu;
|
||||
}
|
||||
|
||||
async function uret(secimler: SihirbazSecimleri) {
|
||||
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
||||
bolumuGorunureAl();
|
||||
/**
|
||||
* Sihirbaz tamamlandı: seçimleri storage'a yaz ve üretimin koştuğu
|
||||
* /listem'e geç. Girişsiz kullanıcı önce girişe uğrar; callback doğrudan
|
||||
* /listem'e düştüğünden üretim dönüşte orada otomatik başlar.
|
||||
*/
|
||||
async function ilerle(secimler: SihirbazSecimleri) {
|
||||
tercihProfiliYaz({ sira, tur, secimler });
|
||||
await modalCikisiniOynat();
|
||||
setElleAcik(false);
|
||||
setDurum("uretiliyor");
|
||||
|
||||
try {
|
||||
const sonuc = await listeOlustur({
|
||||
sira,
|
||||
tur,
|
||||
secimler,
|
||||
requestId: requestIdRef.current,
|
||||
});
|
||||
if (sonuc.ok) {
|
||||
requestIdRef.current = null;
|
||||
olay("liste_olusturuldu", { tur, sira_kovasi: siraKovasi(sira) });
|
||||
setRapor({
|
||||
rapor: sonuc.rapor,
|
||||
params: sonuc.params,
|
||||
revisionCount: sonuc.revisionCount,
|
||||
});
|
||||
setKredi(sonuc.kredi);
|
||||
setPaketli(sonuc.hasPaket);
|
||||
tercihProfiliSil();
|
||||
// Yapay Zeka çıktısı bu sayfada gösterilmez: üretim animasyonu açık kalır,
|
||||
// liste (paketsizse paywall'lı hâliyle) /listem'de karşılar.
|
||||
router.push("/listem");
|
||||
// Header'daki kredi pill'i layout'ta server-render edilir; push layout'u
|
||||
// yeniden çizmediği için düşen bakiye refresh ile güncellenir.
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
// Sunucudan cevap geldiyse harcama ya hiç olmadı ya iade edildi;
|
||||
// sonraki deneme taze requestId ile başlar (aynı id ledger'daki UNIQUE'e
|
||||
// takılıp DUPLICATE çıkmazına giriyordu). requestId yalnızca cevap
|
||||
// alınamayan network hatasında (catch) korunur — orada çifte harcama
|
||||
// riski gerçek.
|
||||
requestIdRef.current = null;
|
||||
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
|
||||
setDurum("kredi-yetersiz");
|
||||
} else if (sonuc.code === "AUTH") {
|
||||
secimleriKaydetVeGirise(secimler);
|
||||
} else {
|
||||
toast.error(sonuc.error);
|
||||
modalaDon();
|
||||
}
|
||||
} catch {
|
||||
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
|
||||
modalaDon();
|
||||
}
|
||||
}
|
||||
|
||||
/** Hata sonrası panelden modala dönüş. */
|
||||
function modalaDon() {
|
||||
setDurum("bos");
|
||||
setElleAcik(true);
|
||||
}
|
||||
|
||||
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
|
||||
tercihProfiliYaz({ sira, tur, secimler });
|
||||
const geri = sonucHref({ sira, tur }, { sihirbaz: "1" });
|
||||
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
|
||||
}
|
||||
|
||||
async function ilerle(secimler: SihirbazSecimleri) {
|
||||
setSonSecimler(secimler);
|
||||
if (!girisliMi) {
|
||||
await modalCikisiniOynat();
|
||||
secimleriKaydetVeGirise(secimler);
|
||||
router.push(`/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`);
|
||||
return;
|
||||
}
|
||||
await uret(secimler);
|
||||
router.push(LISTEM_URET_HREF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Funnel CTA'larının (banner, büyük kart, sabit alt kart) ortak girişi:
|
||||
* Funnel CTA'larının (banner, büyük kart) ortak girişi:
|
||||
* adımlar zaten tamamlanıp kaydedildiyse modal açılmaz — girişli
|
||||
* kullanıcıda üretim doğrudan başlar, girişsiz kullanıcı girişe gider.
|
||||
* kullanıcı doğrudan /listem'e gider, girişsiz kullanıcı girişe gider.
|
||||
* Kayıtlı seçim yoksa (ya da başka sıra/türe aitse) modal açılır.
|
||||
*/
|
||||
function sihirbaziBaslat() {
|
||||
@@ -293,28 +193,28 @@ export function SihirbazBolumu({
|
||||
setElleKapandi(false);
|
||||
}
|
||||
|
||||
// Giriş dönüşü: storage'daki eşleşen seçimlerle otomatik üretime başla
|
||||
// (bir kez, modal açmadan — üretim sayfa gövdesinde oynar).
|
||||
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && !rapor);
|
||||
// Giriş dönüşü (?sihirbaz=1) storage'da eşleşen seçimlerle geldiyse modal
|
||||
// açılmaz: seçimler hazır, üretim /listem'de başlasın (bir kez).
|
||||
const otomatikGonder = Boolean(
|
||||
otomatikAc && bekleyen?.eslesir && girisliMi && !raporVar,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (otomatikUret && bekleyen && !otomatikBasladi.current) {
|
||||
if (otomatikGonder && !otomatikBasladi.current) {
|
||||
otomatikBasladi.current = true;
|
||||
setSonSecimler(bekleyen.secimler);
|
||||
void uret(bekleyen.secimler);
|
||||
// replace: geri tuşu bu sayfaya dönünce tekrar /listem'e sekmesin
|
||||
router.replace(LISTEM_URET_HREF);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [otomatikUret]);
|
||||
}, [otomatikGonder]);
|
||||
|
||||
// ?sihirbaz=1: otomatik üretim devreye girmediyse (seçim kaybolduysa/
|
||||
// ?sihirbaz=1: otomatik yönlendirme devreye girmediyse (seçim kaybolduysa/
|
||||
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin. Karar
|
||||
// storage okunmadan verilmez: kayıtlı seçimlerle gelen kullanıcıya modal
|
||||
// hiç gösterilmez, üretim doğrudan başlar.
|
||||
// hiç gösterilmez, /listem'e geçilir.
|
||||
const acik =
|
||||
!elleKapandi &&
|
||||
(elleAcik ||
|
||||
Boolean(
|
||||
otomatikAc && depoOkundu && !otomatikUret && !rapor && durum === "bos",
|
||||
));
|
||||
Boolean(otomatikAc && depoOkundu && !otomatikGonder && !raporVar));
|
||||
|
||||
function acikDegisti(v: boolean) {
|
||||
if (v) {
|
||||
@@ -326,245 +226,113 @@ export function SihirbazBolumu({
|
||||
}
|
||||
}
|
||||
|
||||
const panelde = Boolean(rapor) || durum !== "bos";
|
||||
|
||||
// Değer önce: sihirbazı bitirmiş ama girişsiz kullanıcı ücretsiz tabloyu
|
||||
// zaten görüyor; giriş burada bariyer değil, hazır seçimlerin (ödülün)
|
||||
// kaydedilmesi olarak sunulur. Girişten dönüşte otomatik üretim başlar.
|
||||
// Sinyal URL'den (?hazir=1) gelir; storage'a bakılsaydı panel anahtarı
|
||||
// mount sonrası değişir ve AnimatePresence geçişi donardı.
|
||||
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !rapor);
|
||||
const girisUrl = `/giris?callback=${encodeURIComponent(
|
||||
sonucHref({ sira, tur }, { sihirbaz: "1" }),
|
||||
)}`;
|
||||
|
||||
const panelAnahtari =
|
||||
durum !== "bos"
|
||||
? durum
|
||||
: rapor
|
||||
? "rapor-hazir"
|
||||
: girisBekliyor
|
||||
? "giris-cta"
|
||||
: "sihirbaz-cta";
|
||||
// kaydedilmesi olarak sunulur. Girişten dönüşte üretim /listem'de başlar.
|
||||
// Sinyal URL'den (?hazir=1) gelir ki SSR'da doğru panel render edilsin.
|
||||
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !raporVar);
|
||||
const girisUrl = `/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`;
|
||||
// Kapı 1 ölçümü: panel yalnızca girisBekliyor dalında render edilir; ref
|
||||
// başka dalda bağlanmadığından observer da yalnızca o durumda kurulur.
|
||||
const hazirPanelRef = useGorunumOlayi<HTMLElement>("hazir_panel_goruntulendi");
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Yapay Zeka çıktısı (rapor listesi/haritası) bu sayfada render edilmez;
|
||||
panel yalnızca üretim ve kredi-yetersiz gibi geçici funnel
|
||||
durumlarını gösterir, listenin kendisi /listem'de yaşar. */}
|
||||
<div ref={bolumRef} className="relative">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.div
|
||||
key={panelAnahtari}
|
||||
initial={
|
||||
hareketiAzalt
|
||||
? { opacity: 0 }
|
||||
: {
|
||||
opacity: 0,
|
||||
transform: "translate3d(0, 8px, 0)",
|
||||
}
|
||||
}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
transform: "translate3d(0, 0, 0)",
|
||||
transition: hareketiAzalt
|
||||
? { duration: 0.12, ease: "easeOut" }
|
||||
: {
|
||||
duration: 0.28,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
},
|
||||
}}
|
||||
exit={
|
||||
hareketiAzalt
|
||||
? {
|
||||
opacity: 0,
|
||||
transition: { duration: 0.08, ease: "easeOut" },
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
transform: "translate3d(0, -4px, 0)",
|
||||
transition: {
|
||||
duration: 0.14,
|
||||
ease: [0.4, 0, 0.7, 0.2],
|
||||
},
|
||||
}
|
||||
}
|
||||
{/* Yapay Zeka çıktısı (üretim animasyonu dahil) bu sayfada render
|
||||
edilmez; panel yalnızca funnel CTA'larını gösterir, üretim ve
|
||||
listenin kendisi /listem'de yaşar. */}
|
||||
{raporVar ? (
|
||||
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
||||
24 tercihlik listen hazır
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
|
||||
seni bekliyor.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
{durum !== "bos" ? (
|
||||
<ListePaneli
|
||||
durum={durum}
|
||||
rapor={null}
|
||||
hasPaket={paketli}
|
||||
kredi={kredi}
|
||||
sira={sira}
|
||||
acilis={acilis}
|
||||
onRevize={async (feedback) => {
|
||||
if (!revizyonIdRef.current) {
|
||||
revizyonIdRef.current = crypto.randomUUID();
|
||||
}
|
||||
const sonuc = await listeRevize({
|
||||
feedback,
|
||||
requestId: revizyonIdRef.current,
|
||||
});
|
||||
revizyonIdRef.current = null;
|
||||
if (sonuc.ok) {
|
||||
olay("liste_revize", { sayfa: "sonuc" });
|
||||
setRapor({
|
||||
rapor: sonuc.rapor,
|
||||
params: sonuc.params,
|
||||
revisionCount: sonuc.revisionCount,
|
||||
});
|
||||
setKredi(sonuc.kredi);
|
||||
toast.success("Listen yeniden kuruldu.");
|
||||
} else {
|
||||
toast.error(sonuc.error);
|
||||
}
|
||||
return sonuc.ok;
|
||||
}}
|
||||
/>
|
||||
) : rapor ? (
|
||||
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
||||
24 tercihlik listen hazır
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
|
||||
seni bekliyor.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/listem">Listeni gör</Link>
|
||||
</Button>
|
||||
</section>
|
||||
) : girisBekliyor ? (
|
||||
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
|
||||
>
|
||||
<PixelField seed={13} />
|
||||
</div>
|
||||
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
|
||||
Seçimlerin hazır — Yapay Zeka listen bir adım uzakta
|
||||
</h2>
|
||||
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
|
||||
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
|
||||
tercihlik Yapay Zeka listeni de kurabilirsin: ücretsiz giriş yap,
|
||||
seçimlerin kayıtlı — listen otomatik kurulsun (üretim 3
|
||||
kredi, kalan 2 kredinle danışmana soru sorarsın).
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
|
||||
>
|
||||
<Link href={girisUrl}>
|
||||
5 deneme kredinle Yapay Zeka listeni kur — giriş yap
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="relative mt-3 text-xs text-white/70">
|
||||
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
|
||||
>
|
||||
<PixelField seed={19} />
|
||||
</div>
|
||||
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
|
||||
24 tercihi tek tek elemek yerine, yapay zekâ kursun
|
||||
</h2>
|
||||
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
|
||||
Tablodan elle eklemek her zaman serbest. Ama üç adımda ilgi
|
||||
alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli
|
||||
ve garanti dilimlerine dağıtılmış 24 tercihi — her satırı
|
||||
yerleşme riski ve gerekçesiyle — senin yerine dizer.
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={sihirbaziBaslat}
|
||||
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
|
||||
>
|
||||
Yapay Zeka listemi kur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Sabit alt çağrı kartı — liste/panel yokken ve kart kapatılmadıysa */}
|
||||
<ViewportPortal>
|
||||
{monteEdildi && !panelde && !kartGizle && !acik ? (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
|
||||
<div className="pointer-events-auto mx-auto flex max-w-xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl">
|
||||
<div className="flex-1">
|
||||
<p className="font-heading text-sm font-bold text-slate-900">
|
||||
{girisBekliyor
|
||||
? "Seçimlerin kayıtlı — Yapay Zeka listen hazır bekliyor"
|
||||
: "Tek tek ekleme, yapay zekâ kursun"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{girisBekliyor
|
||||
? "Ücretsiz giriş yap, 5 deneme kredinle listen otomatik kurulsun."
|
||||
: "Üç adımda seçimlerini söyle; risk analizli, gerekçeli 24 tercih hazır."}
|
||||
</p>
|
||||
</div>
|
||||
{girisBekliyor ? (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href={girisUrl}>Giriş yap</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={sihirbaziBaslat}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Başla
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Kapat"
|
||||
onClick={() => {
|
||||
setKartGizle(true);
|
||||
try {
|
||||
sessionStorage.setItem(KAPATMA_KEY, "1");
|
||||
} catch {}
|
||||
}}
|
||||
className="cursor-pointer rounded-full p-1 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<Link href="/listem">Listeni gör</Link>
|
||||
</Button>
|
||||
</section>
|
||||
) : girisBekliyor ? (
|
||||
<section
|
||||
ref={hazirPanelRef}
|
||||
className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12"
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
|
||||
>
|
||||
<PixelField seed={13} />
|
||||
</div>
|
||||
) : null}
|
||||
</ViewportPortal>
|
||||
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
|
||||
Seçimlerin hazır — Yapay Zeka listen bir adım uzakta
|
||||
</h2>
|
||||
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
|
||||
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
|
||||
tercihlik Yapay Zeka listeni de kurabilirsin: ücretsiz giriş yap,
|
||||
seçimlerin kayıtlı — listen otomatik kurulsun (üretim 3
|
||||
kredi, kalan 2 kredinle danışmana soru sorarsın).
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
|
||||
>
|
||||
<Link
|
||||
href={girisUrl}
|
||||
onClick={() =>
|
||||
olay("giris_cta_tiklandi", { kaynak: "hazir_panel" })
|
||||
}
|
||||
>
|
||||
5 deneme kredinle Yapay Zeka listeni kur — giriş yap
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="relative mt-3 text-xs text-white/70">
|
||||
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
|
||||
>
|
||||
<PixelField seed={19} />
|
||||
</div>
|
||||
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
|
||||
24 tercihi tek tek elemek yerine, yapay zekâ kursun
|
||||
</h2>
|
||||
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
|
||||
Tablodan elle eklemek her zaman serbest. Ama üç adımda ilgi
|
||||
alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli
|
||||
ve garanti dilimlerine dağıtılmış 24 tercihi — her satırı
|
||||
yerleşme riski ve gerekçesiyle — senin yerine dizer.
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={sihirbaziBaslat}
|
||||
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
|
||||
>
|
||||
Yapay Zeka listemi kur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Üretim başladığında modalı kaldır; yeni panelin geçişini Motion
|
||||
üstlenir ve odağı panel içeriğine taşır. */}
|
||||
{monteEdildi && durum === "bos" ? (
|
||||
{monteEdildi ? (
|
||||
<SihirbazModal
|
||||
acik={acik}
|
||||
onAcikDegisti={acikDegisti}
|
||||
sira={sira}
|
||||
tur={tur}
|
||||
facetler={facetler}
|
||||
baslangicSecimler={
|
||||
sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
|
||||
}
|
||||
baslangicSecimler={bekleyen?.eslesir ? bekleyen.secimler : null}
|
||||
sonButonEtiketi={
|
||||
girisliMi ? "Yapay Zeka listemi kur (3 kredi)" : "Giriş yap ve listeni gör"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { SihirbazSecimleri } from "@/lib/sihirbaz";
|
||||
|
||||
/**
|
||||
* Sihirbaz modalı artık yalnızca adımları toplar; üretim, önizleme ve liste
|
||||
* sonuç sayfası gövdesinde yaşar (bkz. liste-paneli.tsx). Akış yönetimi
|
||||
* /listem'de yaşar (bkz. listem/liste-uretici.tsx). Akış yönetimi
|
||||
* sihirbaz-cagri-karti.tsx'teki SihirbazBolumu'ndadır.
|
||||
*/
|
||||
export function SihirbazModal({
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Yapay Zeka danışman sohbeti — sayfa altında yüzen buton + intercom tarzı popup.
|
||||
// Motion dili: panel tetikleyicisinden doğar (transform-origin sağ-alt),
|
||||
// spring ile açılır; çıkış girişten hızlı. Reduced motion'da cross-fade.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { MessageCircleMore, X } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ViewportPortal } from "@/components/viewport-portal";
|
||||
|
||||
function SohbetSkeleton() {
|
||||
return (
|
||||
<div
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
className="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
<Skeleton className="h-5 w-36" />
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center gap-4 px-4 py-10">
|
||||
<Skeleton className="size-7 rounded-full" />
|
||||
<Skeleton className="h-5 w-56 max-w-full" />
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<Skeleton className="mx-auto h-3.5 w-full" />
|
||||
<Skeleton className="mx-auto h-3.5 w-4/5" />
|
||||
</div>
|
||||
<div className="flex max-w-lg flex-wrap justify-center gap-2">
|
||||
{[112, 148, 96, 132].map((w, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="h-7 rounded-full"
|
||||
style={{ width: w }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-slate-100 p-3">
|
||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sohbet istemcisi (markdown zinciriyle birlikte) ağır; popup ilk açıldığında
|
||||
// yüklensin — /sonuc ilk yükünü şişirmesin.
|
||||
const SohbetClient = dynamic(
|
||||
() => import("@/app/listem/sohbet-client").then((m) => m.SohbetClient),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <SohbetSkeleton />,
|
||||
},
|
||||
);
|
||||
|
||||
export function SohbetPopup({ kredi }: { kredi: number }) {
|
||||
const [acik, setAcik] = useState(false);
|
||||
const azMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!acik) return;
|
||||
const dinle = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setAcik(false);
|
||||
};
|
||||
window.addEventListener("keydown", dinle);
|
||||
return () => window.removeEventListener("keydown", dinle);
|
||||
}, [acik]);
|
||||
|
||||
return (
|
||||
<ViewportPortal>
|
||||
<AnimatePresence>
|
||||
{acik ? (
|
||||
<motion.div
|
||||
key="sohbet"
|
||||
initial={
|
||||
azMotion ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.95 }
|
||||
}
|
||||
animate={azMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
|
||||
exit={
|
||||
azMotion
|
||||
? { opacity: 0, transition: { duration: 0.15 } }
|
||||
: {
|
||||
opacity: 0,
|
||||
y: 16,
|
||||
scale: 0.97,
|
||||
transition: { duration: 0.18, ease: [0.23, 1, 0.32, 1] },
|
||||
}
|
||||
}
|
||||
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
|
||||
style={{ transformOrigin: "bottom right" }}
|
||||
className="fixed bottom-24 right-4 z-50 h-[min(72dvh,560px)] w-[min(100vw-2rem,420px)] print:hidden"
|
||||
>
|
||||
<SohbetClient
|
||||
baslangicKredi={kredi}
|
||||
className="h-full shadow-2xl"
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Yüzen tetikleyici — basışta anında küçülür (pointer-down feedback) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAcik((v) => !v)}
|
||||
aria-expanded={acik}
|
||||
className="fixed bottom-6 right-4 z-50 flex h-13 cursor-pointer items-center gap-2 rounded-full bg-slate-900 py-3 pl-4 pr-5 font-heading text-sm font-bold text-white shadow-xl transition-[transform,background-color] duration-150 hover:bg-slate-700 active:scale-[0.95] print:hidden"
|
||||
>
|
||||
{acik ? (
|
||||
<X className="size-5" aria-hidden />
|
||||
) : (
|
||||
<MessageCircleMore className="size-5" aria-hidden />
|
||||
)}
|
||||
{acik ? "Kapat" : "Danışmana sor"}
|
||||
</button>
|
||||
</ViewportPortal>
|
||||
);
|
||||
}
|
||||
173
src/app/sonuc/tadimlik-satiri.tsx
Normal file
173
src/app/sonuc/tadimlik-satiri.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
// Girişsiz yapay zekâ tadımlığı (PRD İP-B): /sonuc'ta, sıra kovası + (varsa)
|
||||
// sihirbaz alan tercihine uyan TEK örnek tercih satırı — gerçek rapor
|
||||
// satırının detay bloğuyla (RaporSatirDetayi) birebir aynı formatta.
|
||||
// İçerik batch üretilmiş havuzdan gelir; bu bileşen LLM'e istek attırmaz.
|
||||
// SSR "genel" satırı render eder (prop), mount sonrası sihirbaz kategorisi
|
||||
// varsa /api/tadimlik'ten kategoriye özel satır çekilip yerinde değiştirilir
|
||||
// (seçim sunucuda kalır). Havuzda içerik yoksa hiç render edilmez.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||
import { RaporSatirDetayi } from "@/components/rapor-listesi";
|
||||
import {
|
||||
ProgramSiraGecmisi,
|
||||
ProgramTabanTrendi,
|
||||
programEtkinSira,
|
||||
raporSiraSerisi,
|
||||
} from "@/components/program-liste-verileri";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { dilimdenRisk, riskHesapla } from "@/lib/risk";
|
||||
import { turkishSlugify } from "@/lib/slug";
|
||||
import {
|
||||
PROFIL_DEGISTI_EVENT,
|
||||
sonucHref,
|
||||
tercihProfiliOku,
|
||||
} from "@/lib/sihirbaz";
|
||||
import type { TadimlikSatir } from "@/lib/tadimlik-havuzu";
|
||||
|
||||
const GENEL = "genel";
|
||||
|
||||
export function TadimlikSatiri({
|
||||
sira,
|
||||
tur,
|
||||
ilk,
|
||||
}: {
|
||||
sira: number;
|
||||
tur: string;
|
||||
/** SSR'da seçilmiş "genel" satır; havuz boşsa null. */
|
||||
ilk: TadimlikSatir | null;
|
||||
}) {
|
||||
const [satir, setSatir] = useState<TadimlikSatir | null>(ilk);
|
||||
const yuklenenSlug = useRef(GENEL);
|
||||
|
||||
// Sihirbaz kategorisi yalnızca localStorage'da yaşar; mount'ta ve profil
|
||||
// değiştikçe okunur, kategoriye özel satır sunucudan çekilir.
|
||||
useEffect(() => {
|
||||
const guncelle = () => {
|
||||
const profil = tercihProfiliOku();
|
||||
const kategori = profil?.secimler.kategoriler[0];
|
||||
const slug = kategori ? turkishSlugify(kategori) : GENEL;
|
||||
if (slug === yuklenenSlug.current) return;
|
||||
yuklenenSlug.current = slug;
|
||||
if (slug === GENEL) {
|
||||
setSatir(ilk);
|
||||
return;
|
||||
}
|
||||
fetch(
|
||||
`/api/tadimlik?sira=${sira}&tur=${encodeURIComponent(tur)}&kategori=${encodeURIComponent(slug)}`,
|
||||
)
|
||||
.then((yanit) => (yanit.ok ? yanit.json() : null))
|
||||
.then((veri: { tadimlik: TadimlikSatir | null } | null) => {
|
||||
// Yarış koruması: bu arada başka kategori yüklendiyse dokunma
|
||||
if (yuklenenSlug.current !== slug) return;
|
||||
if (veri?.tadimlik) setSatir(veri.tadimlik);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
guncelle();
|
||||
window.addEventListener(PROFIL_DEGISTI_EVENT, guncelle);
|
||||
return () => window.removeEventListener(PROFIL_DEGISTI_EVENT, guncelle);
|
||||
}, [sira, tur, ilk]);
|
||||
|
||||
const gorunumRef = useGorunumOlayi<HTMLElement>("tadimlik_goruntulendi", {
|
||||
kova: siraKovasi(sira),
|
||||
});
|
||||
|
||||
if (!satir) return null;
|
||||
|
||||
const p = satir.program;
|
||||
const risk = riskHesapla(p.efektifSira, sira) ?? dilimdenRisk(satir.dilim);
|
||||
const seri = raporSiraSerisi(p.siraGecmisi);
|
||||
const taban = p.efektifSira ?? programEtkinSira(seri);
|
||||
const devlet = p.unitur === "DEVLET";
|
||||
const girisUrl = `/giris?callback=${encodeURIComponent(sonucHref({ sira, tur }))}`;
|
||||
|
||||
return (
|
||||
<section ref={gorunumRef} className="mt-16 min-w-0">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<SectionEyebrow>Yapay zekâdan örnek satır</SectionEyebrow>
|
||||
<h2 className="mt-4 font-heading text-2xl font-bold sm:text-3xl">
|
||||
Yapay zekâ listendeki her satırı böyle anlatır
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||
Sıralamana uygun bir programın gerekçesi, yerleşme riski ve taban
|
||||
trendi — 24 tercihlik listenin her satırı bu formatta kurulur.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:p-6">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
||||
<UniLogo ad={p.universite} boy="sm" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{p.isim}
|
||||
</span>
|
||||
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
|
||||
{p.unitur ? (
|
||||
<span
|
||||
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
|
||||
devlet
|
||||
? "bg-slate-100 text-slate-600"
|
||||
: "bg-violet-50 text-violet-600"
|
||||
}`}
|
||||
>
|
||||
{devlet ? "Devlet" : "Vakıf"}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate">
|
||||
{p.universite}
|
||||
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden text-right text-xs md:inline-flex">
|
||||
<ProgramSiraGecmisi program={seri} />
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs tabular-nums text-slate-500">
|
||||
<ProgramTabanTrendi program={seri} />
|
||||
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg bg-slate-50/60 px-4 py-4">
|
||||
<RaporSatirDetayi
|
||||
dilim={satir.dilim}
|
||||
risk={risk}
|
||||
unitur={p.unitur}
|
||||
gerekce={satir.gerekce}
|
||||
riskNotu={satir.riskNotu}
|
||||
trendOzeti={satir.trendOzeti}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col items-start gap-3 border-t border-slate-100 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-slate-600">
|
||||
Bu, 24 satırlık Yapay Zeka listenden yalnızca bir örnek.
|
||||
<span className="block text-xs text-slate-500">
|
||||
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link
|
||||
href={girisUrl}
|
||||
onClick={() => olay("giris_cta_tiklandi", { kaynak: "tadimlik" })}
|
||||
>
|
||||
24 satırın tamamı için giriş yap — 5 deneme kredin hazır
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChartNoAxesCombined,
|
||||
@@ -76,12 +76,15 @@ const KANITLAR = [
|
||||
export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const profil = useTercihProfili();
|
||||
const [metinler] = useState(() => karistir(FUNNEL_METINLERI));
|
||||
|
||||
function sihirbazaGit() {
|
||||
onSec();
|
||||
if (pathname === "/sonuc") {
|
||||
// Orkestratör (SihirbazBolumu) yalnızca /sonuc?sira= geçerliyken monte;
|
||||
// sırasız /sonuc'ta (SiraGerekli görünümü) olay boşa gider — kapıya düş.
|
||||
if (pathname === "/sonuc" && searchParams.get("sira")) {
|
||||
window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT));
|
||||
return;
|
||||
}
|
||||
@@ -89,7 +92,9 @@ export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
|
||||
router.push(sonucHref(profil, { sihirbaz: "1" }));
|
||||
return;
|
||||
}
|
||||
profilToplamaAc("sonuc");
|
||||
// Yapay Zeka niyetli CTA: kapı tamamlanınca /sonuc'ta üretim akışı devam
|
||||
// etmeli ("sonuc" hedefi ücretsiz tabloda bırakıyor, liste hiç kurulmuyordu).
|
||||
profilToplamaAc("sihirbaz");
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -134,7 +139,7 @@ export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={sihirbazaGit}
|
||||
className="h-11 w-full shrink-0 cursor-pointer rounded-xl bg-orange-500 px-5 font-semibold text-white shadow-[0_8px_24px_rgba(249,115,22,0.28)] transition-[background-color,transform,box-shadow] duration-200 hover:bg-orange-400 hover:shadow-[0_10px_28px_rgba(249,115,22,0.36)] active:scale-[0.98] focus-visible:ring-white/70 sm:w-auto"
|
||||
className="w-full shrink-0 cursor-pointer bg-orange-500 font-semibold text-white shadow-[0_8px_24px_rgba(249,115,22,0.28)] transition-[background-color,transform,box-shadow] duration-200 hover:bg-orange-400 hover:shadow-[0_10px_28px_rgba(249,115,22,0.36)] active:scale-[0.98] focus-visible:ring-white/70 sm:w-auto"
|
||||
>
|
||||
Yapay Zeka listemi kur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ProgramTabanTrendi,
|
||||
} from "@/components/program-liste-verileri";
|
||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
||||
import { ProgramNetleriButonu } from "@/components/program-netleri";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import {
|
||||
Table,
|
||||
@@ -55,7 +56,7 @@ function SiralamaDugmesi({
|
||||
aktif && yon === "artan" ? "azalan" : "artan"
|
||||
} sırala`}
|
||||
aria-pressed={aktif}
|
||||
className="inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-md px-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-full px-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span>{etiket}</span>
|
||||
<span className="flex flex-col -space-y-1" aria-hidden>
|
||||
@@ -180,8 +181,8 @@ export function BolumProgramListesi({
|
||||
onSirala={sirala}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-14 text-right">
|
||||
<span className="sr-only">Listeye ekle</span>
|
||||
<TableHead className="w-24 text-right">
|
||||
<span className="sr-only">Netler ve listeye ekle</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -254,11 +255,19 @@ export function BolumProgramListesi({
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="pr-3 text-right">
|
||||
<ProgramEkleButonu
|
||||
program={p}
|
||||
kaynak="bolum"
|
||||
className="size-8 min-h-8 min-w-8"
|
||||
/>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProgramNetleriButonu
|
||||
programId={p.id}
|
||||
isim={p.isim.trim()}
|
||||
universite={uniAd}
|
||||
className="size-8 min-h-8 min-w-8"
|
||||
/>
|
||||
<ProgramEkleButonu
|
||||
program={p}
|
||||
kaynak="bolum"
|
||||
className="size-8 min-h-8 min-w-8"
|
||||
/>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ const KANITLAR = [
|
||||
*
|
||||
* Profilsiz CTA adayı ana sayfaya göndermez: buton alttan bir çekmece açar,
|
||||
* sıralama girişi ve sihirbaz adayın bulunduğu sayfanın bağlamında akar.
|
||||
* Tasarım gerekçesi için bkz. CTA-CEKMECE-TASARIMI.md.
|
||||
* Tasarım gerekçesi için bkz. docs/tasarim/cta-cekmece-tasarimi.md.
|
||||
*/
|
||||
export function CtaSiraForm({ baslik }: { baslik?: string }) {
|
||||
const profil = useTercihProfili();
|
||||
@@ -153,7 +153,7 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
|
||||
</p>
|
||||
<Link
|
||||
href={sonucHref(profil)}
|
||||
className="mt-4 inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-orange-500 px-6 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
className="mt-4 inline-flex h-12 items-center justify-center gap-2 rounded-full bg-orange-500 px-7 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Sıralamana uygun programları gör
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
// Site kromunun (banner, header, üst/alt blur) gizleneceği rotalar
|
||||
// (tam eşleşme veya alt yol)
|
||||
// (tam eşleşme veya alt yol). KaydetBanner da aynı listeye uyar.
|
||||
const HIDDEN_PREFIXES = ["/giris"];
|
||||
|
||||
export function kromGizliMi(pathname: string) {
|
||||
return HIDDEN_PREFIXES.some(
|
||||
(p) => pathname === p || pathname.startsWith(`${p}/`),
|
||||
);
|
||||
}
|
||||
|
||||
export function HeaderGate({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const gizli = HIDDEN_PREFIXES.some(
|
||||
(p) => pathname === p || pathname.startsWith(`${p}/`),
|
||||
);
|
||||
if (gizli) return null;
|
||||
if (kromGizliMi(pathname)) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function HeroFocusButton() {
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={handleClick}
|
||||
className="mt-8 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Ücretsiz dene
|
||||
</Button>
|
||||
@@ -47,7 +47,7 @@ export function KapanisCta() {
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-8 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href={sonucHref(profil)}>
|
||||
Üniversitelere göz at
|
||||
|
||||
@@ -116,7 +116,7 @@ export function HeroForm() {
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-12 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
|
||||
className="w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link href={sonucHref(kayitliProfil)}>
|
||||
Üniversitelere göz at
|
||||
@@ -140,7 +140,9 @@ export function HeroForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-3">
|
||||
// max-w-xl: yatay dizilimde placeholder'ın tamamı inputa sığsın diye
|
||||
// (md'de buton geniş olduğundan inputa ~200px kalıyor, metin kesiliyordu).
|
||||
<div className="flex w-full max-w-xl flex-col items-center gap-3">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Puan türü"
|
||||
@@ -190,9 +192,9 @@ export function HeroForm() {
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={yukleniyor}
|
||||
className="h-12 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
{yukleniyor ? "Hazırlanıyor…" : "Yapay Zeka listemi kur"}
|
||||
Yapay Zeka listemi kur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -87,7 +87,9 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
||||
const [acik, setAcik] = useState(false);
|
||||
|
||||
function siralamaGirVeyaSonucaGit() {
|
||||
const { href } = sonucaGitIste();
|
||||
// Navbar CTA'sı "Sıralamanı gir" dediği için kapı tanıtım adımını
|
||||
// atlayıp doğrudan başarı sıralaması adımıyla açılır.
|
||||
const { href } = sonucaGitIste({ dogrudanSira: true });
|
||||
if (href) router.push(href);
|
||||
}
|
||||
const [sorgu, setSorgu] = useState("");
|
||||
|
||||
87
src/components/manuel-liste/cihaz-ikonlari.tsx
Normal file
87
src/components/manuel-liste/cihaz-ikonlari.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
// "Listen yalnızca bu tarayıcıda" satırları için cihaz ikonları.
|
||||
//
|
||||
// - IsletimSistemiLogosu: kullanıcının şu an kullandığı işletim sisteminin
|
||||
// logosu (Apple / Windows / Android). UA yalnızca client'ta okunur; ilk
|
||||
// boyada nötr Monitor ikonu basılır ki SSR/hydration uyuşmazlığı olmasın.
|
||||
// iPad "Mac" olarak görünebilir ama ikisi de Apple logosuna düştüğü için
|
||||
// sorun değil; Linux ve bilinmeyenler Monitor'da kalır.
|
||||
// - CihazCifti: "her cihazdan eriş" vurgusu için telefon + bilgisayar ikonu.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Monitor, Smartphone } from "lucide-react";
|
||||
|
||||
type Isletim = "apple" | "windows" | "android" | null;
|
||||
|
||||
function isletimSistemi(): Isletim {
|
||||
const ua = navigator.userAgent;
|
||||
if (/iPhone|iPad|iPod|Mac/i.test(ua)) return "apple";
|
||||
if (/Android/i.test(ua)) return "android";
|
||||
if (/Win/i.test(ua)) return "windows";
|
||||
return null;
|
||||
}
|
||||
|
||||
function AppleLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowsLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M3 3h8.6v8.6H3zM12.4 3H21v8.6h-8.6zM3 12.4h8.6V21H3zM12.4 12.4H21V21h-8.6z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AndroidLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M17.523 15.3414c-.5511 0-.9993-.4486-.9993-.9997s.4482-.9993.9993-.9993c.5511 0 .9993.4482.9993.9993.0001.5511-.4482.9997-.9993.9997m-11.046 0c-.5511 0-.9993-.4486-.9993-.9997s.4482-.9993.9993-.9993c.5511 0 .9993.4482.9993.9993 0 .5511-.4482.9997-.9993.9997m11.4045-6.02l1.9973-3.4592a.416.416 0 0 0-.1521-.5676.416.416 0 0 0-.5676.1521l-2.0223 3.503C15.5902 8.2439 13.8533 7.8508 12 7.8508s-3.5902.3931-5.1367 1.0989L4.841 5.4467a.4161.4161 0 0 0-.5677-.1521.4157.4157 0 0 0-.1521.5676l1.9973 3.4592C2.6889 11.1867.3432 14.6589 0 18.761h24c-.3435-4.1021-2.6892-7.5743-6.1185-9.4396" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IsletimSistemiLogosu({ className }: { className?: string }) {
|
||||
const [os, setOs] = useState<Isletim>(null);
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setOs(isletimSistemi());
|
||||
}, []);
|
||||
|
||||
if (os === "apple") return <AppleLogo className={className} />;
|
||||
if (os === "windows") return <WindowsLogo className={className} />;
|
||||
if (os === "android") return <AndroidLogo className={className} />;
|
||||
return <Monitor className={className} aria-hidden />;
|
||||
}
|
||||
|
||||
export function CihazCifti({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 ${className ?? ""}`}
|
||||
aria-hidden
|
||||
>
|
||||
<Smartphone className="size-3.5" />
|
||||
<Monitor className="size-3.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
63
src/components/manuel-liste/dogrula.ts
Normal file
63
src/components/manuel-liste/dogrula.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Manuel liste kaydının saf doğrulama katmanı. store.ts ("use client")
|
||||
// buradan re-export eder; kayitli-liste-actions.ts ("use server") sunucuda
|
||||
// aynı süzgeci kullanır — client/sunucu iki ayrı doğrulama yazılmaz.
|
||||
|
||||
import type { RiskSeviyesi } from "@/lib/risk";
|
||||
|
||||
export const MANUEL_LISTE_MAX = 24;
|
||||
|
||||
export type ManuelKaynak = "sonuc" | "bolum" | "universite";
|
||||
|
||||
export type ManuelTercih = {
|
||||
/** yokatlas program id'si */
|
||||
id: string;
|
||||
isim: string;
|
||||
universite: string;
|
||||
il: string | null;
|
||||
unitur: string | null;
|
||||
tur: string | null;
|
||||
efektifSira: number | null;
|
||||
/** Ekleme / profil anındaki aday sıralamasına göre risk */
|
||||
risk: RiskSeviyesi | null;
|
||||
kaynak?: ManuelKaynak;
|
||||
};
|
||||
|
||||
/** Güvenilmeyen JSON'u (localStorage veya sunucu kaydı) listeye süzer. */
|
||||
export function dogrula(ham: unknown): ManuelTercih[] {
|
||||
if (!Array.isArray(ham)) return [];
|
||||
const sonuc: ManuelTercih[] = [];
|
||||
for (const t of ham) {
|
||||
if (typeof t !== "object" || t === null) continue;
|
||||
const kayit = t as Record<string, unknown>;
|
||||
if (typeof kayit.id !== "string" || typeof kayit.isim !== "string") continue;
|
||||
const risk: RiskSeviyesi | null =
|
||||
kayit.risk === "guvenli" ||
|
||||
kayit.risk === "az-riskli" ||
|
||||
kayit.risk === "riskli"
|
||||
? kayit.risk
|
||||
: null;
|
||||
const kaynak: ManuelKaynak | undefined =
|
||||
kayit.kaynak === "sonuc" ||
|
||||
kayit.kaynak === "bolum" ||
|
||||
kayit.kaynak === "universite"
|
||||
? kayit.kaynak
|
||||
: undefined;
|
||||
sonuc.push({
|
||||
id: kayit.id,
|
||||
isim: kayit.isim,
|
||||
universite: typeof kayit.universite === "string" ? kayit.universite : "",
|
||||
il: typeof kayit.il === "string" ? kayit.il : null,
|
||||
unitur: typeof kayit.unitur === "string" ? kayit.unitur : null,
|
||||
tur: typeof kayit.tur === "string" ? kayit.tur : null,
|
||||
efektifSira:
|
||||
typeof kayit.efektifSira === "number" &&
|
||||
Number.isFinite(kayit.efektifSira)
|
||||
? kayit.efektifSira
|
||||
: null,
|
||||
risk,
|
||||
kaynak,
|
||||
});
|
||||
if (sonuc.length >= MANUEL_LISTE_MAX) break;
|
||||
}
|
||||
return sonuc;
|
||||
}
|
||||
21
src/components/manuel-liste/giris-url.ts
Normal file
21
src/components/manuel-liste/giris-url.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
// Mevcut sayfaya (query dahil) geri dönen /giris URL'i. Render'ı saf tutmak
|
||||
// için window.location yalnızca effect'te okunur; ilk render callback'siz
|
||||
// /giris ile başlar (banner/çekmece zaten mount sonrası görünür).
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export function useGirisUrl(): string {
|
||||
const pathname = usePathname();
|
||||
const [url, setUrl] = useState("/giris");
|
||||
useEffect(() => {
|
||||
try {
|
||||
const hedef = `${window.location.pathname}${window.location.search}`;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setUrl(`/giris?callback=${encodeURIComponent(hedef)}`);
|
||||
} catch {}
|
||||
}, [pathname]);
|
||||
return url;
|
||||
}
|
||||
11
src/components/manuel-liste/kaydet-banner-lazy.tsx
Normal file
11
src/components/manuel-liste/kaydet-banner-lazy.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
// Banner yalnızca girişsiz + 3 seçimli kullanıcıda görünür; baseline
|
||||
// bundle'a girmesin diye ssr'siz dynamic import (liste-cekmecesi-lazy deseni).
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const KaydetBannerLazy = dynamic(
|
||||
() => import("./kaydet-banner").then((m) => m.KaydetBanner),
|
||||
{ ssr: false },
|
||||
);
|
||||
110
src/components/manuel-liste/kaydet-banner.tsx
Normal file
110
src/components/manuel-liste/kaydet-banner.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
// C2 yumuşak banner: girişsiz kullanıcının manuel listesi 3. programa
|
||||
// ulaşınca bir kez görünen "listen yalnızca bu tarayıcıda" hatırlatması.
|
||||
// /sonuc (funnel panelleriyle yarışmasın) ve giriş gibi kromsuz sayfalar
|
||||
// hariç her yerde görünür; kapatma oturum boyunca kalıcıdır (sessionStorage).
|
||||
// Exit-intent/agresif popup YOK (PRD kuralı).
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ArrowRight, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ViewportPortal } from "@/components/viewport-portal";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import { kromGizliMi } from "@/components/header-gate";
|
||||
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
|
||||
import { useManuelListe } from "./store";
|
||||
import { useOturumDurumu } from "./oturum-sinyali";
|
||||
import { useGirisUrl } from "./giris-url";
|
||||
|
||||
const KAPATMA_KEY = "kolaytercih.kaydet-banner-kapali";
|
||||
const ESIK = 3;
|
||||
|
||||
export function KaydetBanner() {
|
||||
const liste = useManuelListe();
|
||||
const girisli = useOturumDurumu();
|
||||
const pathname = usePathname();
|
||||
const girisUrl = useGirisUrl();
|
||||
|
||||
// SSR/ilk boyada gizli başla; kapatma durumu mount'ta okunur.
|
||||
const [kapali, setKapali] = useState(true);
|
||||
useEffect(() => {
|
||||
try {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setKapali(Boolean(sessionStorage.getItem(KAPATMA_KEY)));
|
||||
} catch {
|
||||
setKapali(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const gorunumRef = useGorunumOlayi<HTMLDivElement>(
|
||||
"kaydet_banner_goruntulendi",
|
||||
);
|
||||
|
||||
if (girisli !== false || liste.length < ESIK || kapali) return null;
|
||||
|
||||
// /sonuc'ta gösterilmez: sayfanın kendi funnel panelleri var.
|
||||
if (pathname?.startsWith("/sonuc")) return null;
|
||||
|
||||
// Giriş gibi kromsuz sayfalarda gösterilmez: kullanıcı zaten giriş
|
||||
// akışındayken "Giriş yap" CTA'sı gereksiz gürültü olur.
|
||||
if (pathname && kromGizliMi(pathname)) return null;
|
||||
|
||||
return (
|
||||
<ViewportPortal>
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
|
||||
<div
|
||||
ref={gorunumRef}
|
||||
className="pointer-events-auto mx-auto flex max-w-xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl"
|
||||
>
|
||||
{/* Kullanıcının o anki işletim sisteminin logosu: "bu tarayıcıda"
|
||||
mesajını somutlaştırır */}
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 text-slate-700">
|
||||
<IsletimSistemiLogosu className="size-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-heading text-sm font-bold text-slate-900">
|
||||
Listen yalnızca bu tarayıcıda
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Giriş yap; {liste.length} seçimin hesabında saklansın, her
|
||||
cihazdan aç{" "}
|
||||
<CihazCifti className="ml-0.5 align-[-3px] text-slate-400" />
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link
|
||||
href={girisUrl}
|
||||
onClick={() =>
|
||||
olay("giris_cta_tiklandi", { kaynak: "liste_kaydet_banner" })
|
||||
}
|
||||
>
|
||||
Giriş yap, kaydet
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Kapat"
|
||||
onClick={() => {
|
||||
setKapali(true);
|
||||
try {
|
||||
sessionStorage.setItem(KAPATMA_KEY, "1");
|
||||
} catch {}
|
||||
}}
|
||||
className="cursor-pointer rounded-full p-1 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ViewportPortal>
|
||||
);
|
||||
}
|
||||
91
src/components/manuel-liste/kayitli-liste-actions.ts
Normal file
91
src/components/manuel-liste/kayitli-liste-actions.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
"use server";
|
||||
|
||||
// Manuel liste + sihirbaz profilinin sunucu kaydı (İP-C). Upsert deseni
|
||||
// reports ile aynıdır (bkz. sonuc/actions.ts): userId unique, tek satır.
|
||||
//
|
||||
// Senkron kuralları (PRD C1):
|
||||
// - Giriş anında listeSenkronla: çakışmada CLIENT kazanır (lokal sıra başa,
|
||||
// sunucudaki eksik id'ler sona), 24 sınırı korunur; idempotent — aynı
|
||||
// girdiyle tekrar çağrı aynı sonucu üretir, çift kayıt imkânsız.
|
||||
// - Girişliyken listeyiKaydet: localStorage tek kaynaktır, sunucu kaydının
|
||||
// üzerine yazılır (son yazan cihaz kazanır).
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { tercihProfiliDogrula, type TercihProfili } from "@/lib/sihirbaz";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
dogrula,
|
||||
type ManuelTercih,
|
||||
} from "@/components/manuel-liste/dogrula";
|
||||
|
||||
export type ListeSenkronSonucu =
|
||||
| { ok: true; items: ManuelTercih[]; profil: TercihProfili | null }
|
||||
| { ok: false };
|
||||
|
||||
async function kaydiYaz(
|
||||
userId: string,
|
||||
items: ManuelTercih[],
|
||||
profil: TercihProfili | null,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
await appDb
|
||||
.insert(schema.savedLists)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
items,
|
||||
profil,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.savedLists.userId,
|
||||
set: { items, profil, updatedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
/** Giriş anı merge'i: lokal + sunucu birleşir, kanonik sonuç döner. */
|
||||
export async function listeSenkronla(girdi: {
|
||||
items: unknown;
|
||||
profil: unknown;
|
||||
}): Promise<ListeSenkronSonucu> {
|
||||
const session = await getSession();
|
||||
if (!session) return { ok: false };
|
||||
const userId = session.user.id;
|
||||
|
||||
const clientItems = dogrula(girdi.items);
|
||||
const clientProfil = tercihProfiliDogrula(girdi.profil);
|
||||
|
||||
const mevcut = await appDb.query.savedLists.findFirst({
|
||||
where: eq(schema.savedLists.userId, userId),
|
||||
});
|
||||
const sunucuItems = dogrula(mevcut?.items);
|
||||
const sunucuProfil = tercihProfiliDogrula(mevcut?.profil);
|
||||
|
||||
const clientIdler = new Set(clientItems.map((t) => t.id));
|
||||
const items = [
|
||||
...clientItems,
|
||||
...sunucuItems.filter((t) => !clientIdler.has(t.id)),
|
||||
].slice(0, MANUEL_LISTE_MAX);
|
||||
const profil = clientProfil ?? sunucuProfil;
|
||||
|
||||
await kaydiYaz(userId, items, profil);
|
||||
return { ok: true, items, profil };
|
||||
}
|
||||
|
||||
/** Girişliyken süregelen senkron: lokal durum sunucunun üzerine yazılır. */
|
||||
export async function listeyiKaydet(girdi: {
|
||||
items: unknown;
|
||||
profil: unknown;
|
||||
}): Promise<{ ok: boolean }> {
|
||||
const session = await getSession();
|
||||
if (!session) return { ok: false };
|
||||
await kaydiYaz(
|
||||
session.user.id,
|
||||
dogrula(girdi.items),
|
||||
tercihProfiliDogrula(girdi.profil),
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -45,7 +45,11 @@ import {
|
||||
} from "motion/react";
|
||||
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
|
||||
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
|
||||
import { sonucaGitIste } from "./profil-kapisi-store";
|
||||
import { useOturumDurumu } from "./oturum-sinyali";
|
||||
import { useGirisUrl } from "./giris-url";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
cekmeceAc,
|
||||
@@ -81,6 +85,8 @@ export function ListeCekmecesi() {
|
||||
const router = useRouter();
|
||||
const acik = useCekmeceAcik();
|
||||
const liste = useManuelListe();
|
||||
const girisli = useOturumDurumu();
|
||||
const girisUrl = useGirisUrl();
|
||||
// Giriş animasyonu bitmeden satır layout projeksiyonu kapalı — transform'lu
|
||||
// drawer içinde erken ölçüm satırları kaydırıyordu
|
||||
const [oturdu, setOturdu] = useState(false);
|
||||
@@ -216,7 +222,7 @@ export function ListeCekmecesi() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={programaEkleGit}
|
||||
className={`flex h-12 w-full cursor-pointer items-center justify-center rounded-xl border border-dashed border-slate-300 text-slate-400 transition-colors duration-200 hover:border-primary/50 hover:bg-primary/5 hover:text-primary active:scale-[0.98] ${
|
||||
className={`flex h-12 w-full cursor-pointer items-center justify-center rounded-full border border-dashed border-slate-300 text-slate-400 transition-colors duration-200 hover:border-primary/50 hover:bg-primary/5 hover:text-primary active:scale-[0.98] ${
|
||||
liste.length > 0 ? "mt-2" : "mt-1"
|
||||
}`}
|
||||
>
|
||||
@@ -227,6 +233,32 @@ export function ListeCekmecesi() {
|
||||
</div>
|
||||
|
||||
<DrawerFooter className="border-t border-slate-100 pt-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||
{/* C2 kalıcı satır: girişsiz kullanıcıya listenin uçucu olduğu
|
||||
hatırlatılır; giriş doğal "kaydet" gerekçesi olarak sunulur. */}
|
||||
{girisli === false ? (
|
||||
<div className="mb-3 flex items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-left">
|
||||
<p className="min-w-0 text-xs leading-relaxed text-slate-600">
|
||||
{/* O anki işletim sisteminin logosu — "bu tarayıcıda"yı somutlaştırır */}
|
||||
<IsletimSistemiLogosu className="mr-1.5 inline size-3.5 align-[-3px] text-slate-700" />
|
||||
<span className="font-semibold text-slate-800">
|
||||
Listen yalnızca bu tarayıcıda.
|
||||
</span>{" "}
|
||||
Giriş yap; her cihazdan eriş{" "}
|
||||
<CihazCifti className="align-[-3px] text-slate-400" />
|
||||
</p>
|
||||
<Link
|
||||
href={girisUrl}
|
||||
onClick={() => {
|
||||
cekmeceKapat();
|
||||
olay("giris_cta_tiklandi", { kaynak: "liste_cekmecesi" });
|
||||
}}
|
||||
className="inline-flex shrink-0 items-center gap-1 text-xs font-semibold text-primary transition-colors duration-200 hover:text-primary/80"
|
||||
>
|
||||
Giriş yap, kaydet
|
||||
<ArrowRight className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Ana sayfadaki final CTA panelinin kompakt hâli: koyu primary
|
||||
zemin + kenarlara doğru yoğunlaşan piksel dokusu (PixelField) */}
|
||||
<div className="relative isolate flex items-center justify-between gap-4 overflow-hidden rounded-2xl bg-primary px-5 py-5 text-left text-white">
|
||||
@@ -247,7 +279,7 @@ export function ListeCekmecesi() {
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-11 shrink-0 cursor-pointer bg-orange-500 px-6 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
className="shrink-0 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/listem" onClick={cekmeceKapat}>
|
||||
Listemde aç
|
||||
|
||||
100
src/components/manuel-liste/liste-senkron.tsx
Normal file
100
src/components/manuel-liste/liste-senkron.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
// Manuel liste + sihirbaz profilinin sunucu senkronu (İP-C). UserNav'dan
|
||||
// (sunucu bileşeni) userId prop'uyla monte edilir — RybbitIdentify ile aynı
|
||||
// kanonik desen. Hiçbir şey render etmez.
|
||||
//
|
||||
// - Mount: oturum sinyali yayılır (çekmece/banner girişsizliği buradan öğrenir).
|
||||
// - Girişte: oturum başına 1 kez merge (çakışmada lokal kazanır); dönen
|
||||
// kanonik liste lokale birleşir, profil lokalde yoksa tercihProfiliYaz ile
|
||||
// iner (PROFIL_DEGISTI_EVENT otomatik yayılır, profil kapısı tazelenir).
|
||||
// - Sonrasında: liste/profil değişimleri ~2 sn debounce ile sunucuya yazılır.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
PROFIL_DEGISTI_EVENT,
|
||||
tercihProfiliOku,
|
||||
tercihProfiliYaz,
|
||||
} from "@/lib/sihirbaz";
|
||||
import { listeOku, sunucuylaBirlestir, useManuelListe } from "./store";
|
||||
import { oturumDurumuBildir } from "./oturum-sinyali";
|
||||
import { listeSenkronla, listeyiKaydet } from "./kayitli-liste-actions";
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
|
||||
export function ListeSenkron({ userId }: { userId: string | null }) {
|
||||
const liste = useManuelListe();
|
||||
const mergeTamam = useRef(false);
|
||||
const sonGonderilen = useRef<string | null>(null);
|
||||
const zamanlayici = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
oturumDurumuBildir(Boolean(userId));
|
||||
}, [userId]);
|
||||
|
||||
// Giriş anı merge'i — sekme oturumu başına bir kez (idempotent olsa da
|
||||
// her gezinmede sunucuya gitmesin diye sessionStorage ile tekilleştirilir).
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
const anahtar = `kolaytercih.liste-senkron.${userId}`;
|
||||
try {
|
||||
if (sessionStorage.getItem(anahtar)) {
|
||||
mergeTamam.current = true;
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
let iptal = false;
|
||||
void (async () => {
|
||||
const sonuc = await listeSenkronla({
|
||||
items: listeOku(),
|
||||
profil: tercihProfiliOku(),
|
||||
});
|
||||
if (iptal || !sonuc.ok) return;
|
||||
sunucuylaBirlestir(sonuc.items);
|
||||
if (sonuc.profil && !tercihProfiliOku()) {
|
||||
tercihProfiliYaz(sonuc.profil);
|
||||
}
|
||||
// Merge sonrası anlık durumu "gönderildi" say — debounce aynı veriyi
|
||||
// hemen geri yazmasın.
|
||||
sonGonderilen.current = JSON.stringify({
|
||||
items: listeOku(),
|
||||
profil: tercihProfiliOku(),
|
||||
});
|
||||
mergeTamam.current = true;
|
||||
try {
|
||||
sessionStorage.setItem(anahtar, "1");
|
||||
} catch {}
|
||||
})();
|
||||
return () => {
|
||||
iptal = true;
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
// Girişliyken süregelen senkron: liste (store aboneliği) ve profil
|
||||
// (PROFIL_DEGISTI_EVENT) değişimlerini debounce ile sunucuya it.
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
|
||||
const planla = () => {
|
||||
if (zamanlayici.current != null) window.clearTimeout(zamanlayici.current);
|
||||
zamanlayici.current = window.setTimeout(() => {
|
||||
if (!mergeTamam.current) return;
|
||||
const items = listeOku();
|
||||
const profil = tercihProfiliOku();
|
||||
const imza = JSON.stringify({ items, profil });
|
||||
if (imza === sonGonderilen.current) return;
|
||||
sonGonderilen.current = imza;
|
||||
void listeyiKaydet({ items, profil });
|
||||
}, DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
planla();
|
||||
window.addEventListener(PROFIL_DEGISTI_EVENT, planla);
|
||||
return () => {
|
||||
window.removeEventListener(PROFIL_DEGISTI_EVENT, planla);
|
||||
if (zamanlayici.current != null) window.clearTimeout(zamanlayici.current);
|
||||
};
|
||||
}, [userId, liste]);
|
||||
|
||||
return null;
|
||||
}
|
||||
34
src/components/manuel-liste/oturum-sinyali.ts
Normal file
34
src/components/manuel-liste/oturum-sinyali.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
// Oturum durumunun client tarafındaki hafif sinyali. Kaynak: UserNav (sunucu
|
||||
// bileşeni) → ListeSenkron mount'ta bildirir. Çekmece ve kaydet banner'ı
|
||||
// authClient round-trip'i atmadan "girişsiz mi?" bilgisine buradan erişir
|
||||
// (layout'a useSession koymak landing'i statiklikten çıkarırdı —
|
||||
// rybbit-identify.tsx'teki gerekçe).
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
let girisli: boolean | null = null;
|
||||
const dinleyiciler = new Set<() => void>();
|
||||
|
||||
export function oturumDurumuBildir(deger: boolean): void {
|
||||
if (girisli === deger) return;
|
||||
girisli = deger;
|
||||
for (const cb of dinleyiciler) cb();
|
||||
}
|
||||
|
||||
function abone(cb: () => void) {
|
||||
dinleyiciler.add(cb);
|
||||
return () => {
|
||||
dinleyiciler.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
/** null = henüz bilinmiyor (sinyal gelmedi); banner bu durumda gösterilmez. */
|
||||
export function useOturumDurumu(): boolean | null {
|
||||
return useSyncExternalStore(
|
||||
abone,
|
||||
() => girisli,
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
@@ -39,12 +39,17 @@ export type BekleyenProgram = {
|
||||
adayTur?: string;
|
||||
};
|
||||
|
||||
/** Kapı tamamlanınca nereye gideceği (sıra yazıldıktan sonra href üretilir). */
|
||||
export type BekleyenHedef = "sonuc";
|
||||
/** Kapı tamamlanınca nereye gideceği (sıra yazıldıktan sonra href üretilir).
|
||||
* "sonuc": ücretsiz tabloya götürür. "sihirbaz": Yapay Zeka niyetli CTA'lardan
|
||||
* gelindi — /sonuc'ta üretim akışı devam etsin (girişliyse otomatik üretim,
|
||||
* girişsizse giriş CTA paneli; hero-form ile aynı desen). */
|
||||
export type BekleyenHedef = "sonuc" | "sihirbaz";
|
||||
|
||||
type KapıDurumu = {
|
||||
acik: boolean;
|
||||
duzenle: boolean;
|
||||
/** Tanıtım (giriş) adımını atla, doğrudan sıralama adımıyla aç. */
|
||||
dogrudanSira: boolean;
|
||||
bekleyen: BekleyenProgram | null;
|
||||
bekleyenHedef: BekleyenHedef | null;
|
||||
profil: TercihProfili | null;
|
||||
@@ -54,6 +59,7 @@ type KapıDurumu = {
|
||||
let durum: KapıDurumu = {
|
||||
acik: false,
|
||||
duzenle: false,
|
||||
dogrudanSira: false,
|
||||
bekleyen: null,
|
||||
bekleyenHedef: null,
|
||||
profil: null,
|
||||
@@ -104,6 +110,7 @@ function abone(cb: () => void) {
|
||||
const SSR_DURUM: KapıDurumu = {
|
||||
acik: false,
|
||||
duzenle: false,
|
||||
dogrudanSira: false,
|
||||
bekleyen: null,
|
||||
bekleyenHedef: null,
|
||||
profil: null,
|
||||
@@ -130,6 +137,7 @@ export function profilKapisiniKapat() {
|
||||
...durum,
|
||||
acik: false,
|
||||
duzenle: false,
|
||||
dogrudanSira: false,
|
||||
bekleyen: null,
|
||||
bekleyenHedef: null,
|
||||
};
|
||||
@@ -142,6 +150,7 @@ export function profilDuzenlemeyiAc() {
|
||||
...durum,
|
||||
acik: true,
|
||||
duzenle: true,
|
||||
dogrudanSira: false,
|
||||
bekleyen: null,
|
||||
bekleyenHedef: null,
|
||||
};
|
||||
@@ -151,13 +160,18 @@ export function profilDuzenlemeyiAc() {
|
||||
/**
|
||||
* Profil yokken kapıyı açar; tamamlanınca `hedef`'e yönlendirilir.
|
||||
* Navbar, çekmece +, /sonuc boş hali ve listem gibi yerler bunu kullanır.
|
||||
* `dogrudanSira` tanıtım adımını atlar — kapı sıralama adımıyla açılır.
|
||||
*/
|
||||
export function profilToplamaAc(hedef: BekleyenHedef | null = null) {
|
||||
export function profilToplamaAc(
|
||||
hedef: BekleyenHedef | null = null,
|
||||
dogrudanSira = false,
|
||||
) {
|
||||
if (!durum.profilYuklendi) yukle();
|
||||
durum = {
|
||||
...durum,
|
||||
acik: true,
|
||||
duzenle: false,
|
||||
dogrudanSira,
|
||||
bekleyen: null,
|
||||
bekleyenHedef: hedef,
|
||||
};
|
||||
@@ -167,8 +181,9 @@ export function profilToplamaAc(hedef: BekleyenHedef | null = null) {
|
||||
/**
|
||||
* Sonuç sayfasına gitmek isteyen her yer: profil varsa href,
|
||||
* yoksa sıralama/tercih kapısını açar (tamamlanınca /sonuc'a gider).
|
||||
* `dogrudanSira`, kapı açılırsa tanıtım adımını atlatır (navbar CTA'sı).
|
||||
*/
|
||||
export function sonucaGitIste(): {
|
||||
export function sonucaGitIste(secenekler?: { dogrudanSira?: boolean }): {
|
||||
href: string | null;
|
||||
kapiAcildi: boolean;
|
||||
} {
|
||||
@@ -177,7 +192,7 @@ export function sonucaGitIste(): {
|
||||
if (profil) {
|
||||
return { href: sonucHref(profil), kapiAcildi: false };
|
||||
}
|
||||
profilToplamaAc("sonuc");
|
||||
profilToplamaAc("sonuc", secenekler?.dogrudanSira ?? false);
|
||||
return { href: null, kapiAcildi: true };
|
||||
}
|
||||
|
||||
@@ -203,6 +218,7 @@ export function programEkleIste(opts: {
|
||||
...durum,
|
||||
acik: true,
|
||||
duzenle: false,
|
||||
dogrudanSira: false,
|
||||
bekleyen: { program, kaynak, adaySira, adayTur },
|
||||
bekleyenHedef: null,
|
||||
};
|
||||
@@ -227,8 +243,13 @@ export function programEkleIste(opts: {
|
||||
return { eklendi: true, zatenVar: false, doldu: false, kapiAcildi: false };
|
||||
}
|
||||
|
||||
/** Profil yazıldıktan sonra; bekleyen hedef varsa yönlendirme href'i döner. */
|
||||
export function profilTamamlandi(profil: TercihProfili): string | null {
|
||||
/** Profil yazıldıktan sonra; bekleyen hedef varsa yönlendirme href'i döner.
|
||||
* `girisli`, "sihirbaz" hedefinde üretimin otomatik başlayıp başlamayacağını
|
||||
* belirler (kapı bileşeni oturumu facet isteğiyle paralel çözer). */
|
||||
export function profilTamamlandi(
|
||||
profil: TercihProfili,
|
||||
girisli = false,
|
||||
): string | null {
|
||||
const onceki = durum.profil;
|
||||
const duzenle = durum.duzenle;
|
||||
riskleriYenile(profil);
|
||||
@@ -241,12 +262,19 @@ export function profilTamamlandi(profil: TercihProfili): string | null {
|
||||
...durum,
|
||||
acik: false,
|
||||
duzenle: false,
|
||||
dogrudanSira: false,
|
||||
bekleyen: null,
|
||||
bekleyenHedef: null,
|
||||
profil,
|
||||
profilYuklendi: true,
|
||||
};
|
||||
yayinla();
|
||||
if (hedef === "sihirbaz") {
|
||||
// Yapay Zeka niyetli giriş: kapıda sihirbazı zaten doldurdu, /sonuc'ta
|
||||
// akış kesilmesin — girişliyse üretim otomatik başlar (?sihirbaz=1),
|
||||
// girişsizse ücretsiz tablo + giriş CTA paneli (?hazir=1) karşılar.
|
||||
return sonucHref(profil, girisli ? { sihirbaz: "1" } : { hazir: "1" });
|
||||
}
|
||||
if (hedef === "sonuc") return sonucHref(profil);
|
||||
// Düzenlemede sıra/tür değişince /sonuc?sira= URL'si yenilenmezse sunucudaki
|
||||
// hayal/dengeli/garanti tablosu eski sıralamada kalır (profil localStorage'da
|
||||
|
||||
@@ -7,31 +7,24 @@
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { Program } from "@/lib/db";
|
||||
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
||||
import { riskHesapla } from "@/lib/risk";
|
||||
import {
|
||||
tercihProfiliOku,
|
||||
type TercihProfili,
|
||||
} from "@/lib/sihirbaz";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
dogrula,
|
||||
type ManuelKaynak,
|
||||
type ManuelTercih,
|
||||
} from "./dogrula";
|
||||
|
||||
// Doğrulama katmanı sunucu action'larıyla paylaşılır (bkz. dogrula.ts);
|
||||
// mevcut import yolları kırılmasın diye buradan re-export edilir.
|
||||
export { MANUEL_LISTE_MAX, type ManuelKaynak, type ManuelTercih };
|
||||
|
||||
export const MANUEL_LISTE_MAX = 24;
|
||||
const STORAGE_KEY = "kolaytercih.manuel-liste.v1";
|
||||
|
||||
export type ManuelKaynak = "sonuc" | "bolum" | "universite";
|
||||
|
||||
export type ManuelTercih = {
|
||||
/** yokatlas program id'si */
|
||||
id: string;
|
||||
isim: string;
|
||||
universite: string;
|
||||
il: string | null;
|
||||
unitur: string | null;
|
||||
tur: string | null;
|
||||
efektifSira: number | null;
|
||||
/** Ekleme / profil anındaki aday sıralamasına göre risk */
|
||||
risk: RiskSeviyesi | null;
|
||||
kaynak?: ManuelKaynak;
|
||||
};
|
||||
|
||||
// SSR snapshot'ı: her çağrıda aynı referans dönmeli, yoksa React döngüye girer
|
||||
const BOS_LISTE: ManuelTercih[] = [];
|
||||
|
||||
@@ -51,45 +44,6 @@ function kaydet() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function dogrula(ham: unknown): ManuelTercih[] {
|
||||
if (!Array.isArray(ham)) return [];
|
||||
const sonuc: ManuelTercih[] = [];
|
||||
for (const t of ham) {
|
||||
if (typeof t !== "object" || t === null) continue;
|
||||
const kayit = t as Record<string, unknown>;
|
||||
if (typeof kayit.id !== "string" || typeof kayit.isim !== "string") continue;
|
||||
const risk: RiskSeviyesi | null =
|
||||
kayit.risk === "guvenli" ||
|
||||
kayit.risk === "az-riskli" ||
|
||||
kayit.risk === "riskli"
|
||||
? kayit.risk
|
||||
: null;
|
||||
const kaynak: ManuelKaynak | undefined =
|
||||
kayit.kaynak === "sonuc" ||
|
||||
kayit.kaynak === "bolum" ||
|
||||
kayit.kaynak === "universite"
|
||||
? kayit.kaynak
|
||||
: undefined;
|
||||
sonuc.push({
|
||||
id: kayit.id,
|
||||
isim: kayit.isim,
|
||||
universite: typeof kayit.universite === "string" ? kayit.universite : "",
|
||||
il: typeof kayit.il === "string" ? kayit.il : null,
|
||||
unitur: typeof kayit.unitur === "string" ? kayit.unitur : null,
|
||||
tur: typeof kayit.tur === "string" ? kayit.tur : null,
|
||||
efektifSira:
|
||||
typeof kayit.efektifSira === "number" &&
|
||||
Number.isFinite(kayit.efektifSira)
|
||||
? kayit.efektifSira
|
||||
: null,
|
||||
risk,
|
||||
kaynak,
|
||||
});
|
||||
if (sonuc.length >= MANUEL_LISTE_MAX) break;
|
||||
}
|
||||
return sonuc;
|
||||
}
|
||||
|
||||
function yukle() {
|
||||
if (yuklendi || typeof window === "undefined") return;
|
||||
yuklendi = true;
|
||||
@@ -139,6 +93,28 @@ export function listedeMi(id: string): boolean {
|
||||
return liste.some((t) => t.id === id);
|
||||
}
|
||||
|
||||
/** Senkron snapshot — giriş anı merge'inde sunucuya gönderilecek liste. */
|
||||
export function listeOku(): ManuelTercih[] {
|
||||
yukle();
|
||||
return liste;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sunucudan dönen kanonik listeyi lokale birleştirir: lokal sıra korunur,
|
||||
* lokalde olmayan sunucu kayıtları sona eklenir, 24 sınırı aşılmaz.
|
||||
* (Çakışmada localStorage kazanır — PRD C1 kuralı.)
|
||||
*/
|
||||
export function sunucuylaBirlestir(sunucuItems: ManuelTercih[]): ManuelTercih[] {
|
||||
yukle();
|
||||
const mevcut = new Set(liste.map((t) => t.id));
|
||||
const ekler = sunucuItems.filter((t) => !mevcut.has(t.id));
|
||||
if (ekler.length === 0) return liste;
|
||||
liste = [...liste, ...ekler].slice(0, MANUEL_LISTE_MAX);
|
||||
kaydet();
|
||||
yayinla();
|
||||
return liste;
|
||||
}
|
||||
|
||||
export function efektifSiraAl(
|
||||
p: Pick<Program, "sira2025" | "sira2024">,
|
||||
): number | null {
|
||||
|
||||
@@ -33,10 +33,12 @@ import {
|
||||
type SihirbazSecimleri,
|
||||
type TercihProfili,
|
||||
} from "@/lib/sihirbaz";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import {
|
||||
profilKapisiniKapat,
|
||||
profilTamamlandi,
|
||||
useProfilKapisi,
|
||||
type BekleyenHedef,
|
||||
type BekleyenProgram,
|
||||
} from "./profil-kapisi-store";
|
||||
|
||||
@@ -53,7 +55,8 @@ type Adim = "giris" | "sira" | "sihirbaz";
|
||||
const ADIM_SIRASI: Record<Adim, number> = { giris: 0, sira: 1, sihirbaz: 2 };
|
||||
|
||||
export function TercihProfiliKapisi() {
|
||||
const { acik, duzenle, bekleyen, profil } = useProfilKapisi();
|
||||
const { acik, duzenle, dogrudanSira, bekleyen, bekleyenHedef, profil } =
|
||||
useProfilKapisi();
|
||||
const oturumAnahtari = acik
|
||||
? `${duzenle ? "d" : "n"}-${bekleyen?.program.id ?? "yok"}-${profil?.sira ?? 0}-${profil?.tur ?? ""}`
|
||||
: "kapali";
|
||||
@@ -80,7 +83,9 @@ export function TercihProfiliKapisi() {
|
||||
<KapıIcerik
|
||||
key={oturumAnahtari}
|
||||
duzenle={duzenle}
|
||||
dogrudanSira={dogrudanSira}
|
||||
bekleyen={bekleyen}
|
||||
hedef={bekleyenHedef}
|
||||
profil={profil}
|
||||
/>
|
||||
) : null}
|
||||
@@ -91,19 +96,25 @@ export function TercihProfiliKapisi() {
|
||||
|
||||
function KapıIcerik({
|
||||
duzenle,
|
||||
dogrudanSira,
|
||||
bekleyen,
|
||||
hedef,
|
||||
profil,
|
||||
}: {
|
||||
duzenle: boolean;
|
||||
dogrudanSira: boolean;
|
||||
bekleyen: BekleyenProgram | null;
|
||||
hedef: BekleyenHedef | null;
|
||||
profil: TercihProfili | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const bilinenSira = bekleyen?.adaySira ?? profil?.sira ?? null;
|
||||
const bilinenTur = bekleyen?.adayTur ?? profil?.tur ?? "say";
|
||||
|
||||
// Düzenlemede doğrudan sıralama adımı — yeni sıra + tercihler aynı oturumda
|
||||
const [adim, setAdim] = useState<Adim>(duzenle ? "sira" : "giris");
|
||||
// Düzenlemede ve navbar "Sıralamanı gir" girişinde doğrudan sıralama adımı
|
||||
const [adim, setAdim] = useState<Adim>(
|
||||
duzenle || dogrudanSira ? "sira" : "giris",
|
||||
);
|
||||
// Adım geçişleri sihirbazın kendi içindeki TransitionPanel kaymasıyla oynar
|
||||
const [yon, setYon] = useState(1);
|
||||
const [gecisBasladi, setGecisBasladi] = useState(false);
|
||||
@@ -117,6 +128,9 @@ function KapıIcerik({
|
||||
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
|
||||
const [yukleniyor, setYukleniyor] = useState(false);
|
||||
const [hata, setHata] = useState<string | null>(null);
|
||||
// "sihirbaz" hedefinde yönlendirme ve son buton etiketi oturuma göre
|
||||
// değişir; oturum facet isteğiyle paralel çözülür (hero-form deseni).
|
||||
const [girisli, setGirisli] = useState(false);
|
||||
|
||||
function siralamaDegisti(ham: string) {
|
||||
setHata(null);
|
||||
@@ -134,11 +148,13 @@ function KapıIcerik({
|
||||
setYukleniyor(true);
|
||||
setHata(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/facetler?sira=${siraDeger}&tur=${turDeger}`,
|
||||
);
|
||||
const [res, oturum] = await Promise.all([
|
||||
fetch(`/api/facetler?sira=${siraDeger}&tur=${turDeger}`),
|
||||
authClient.getSession().catch(() => null),
|
||||
]);
|
||||
if (!res.ok) throw new Error();
|
||||
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
|
||||
setGirisli(Boolean(oturum?.data?.user));
|
||||
setSira(siraDeger);
|
||||
setTur(turDeger);
|
||||
setFacetler(veri.facetler);
|
||||
@@ -179,8 +195,10 @@ function KapıIcerik({
|
||||
const yeniProfil: TercihProfili = { sira, tur, secimler };
|
||||
tercihProfiliYaz(yeniProfil);
|
||||
const bekleyenVar = Boolean(bekleyen);
|
||||
const yonlendir = profilTamamlandi(yeniProfil);
|
||||
if (!duzenle && !bekleyenVar) {
|
||||
const yonlendir = profilTamamlandi(yeniProfil, girisli);
|
||||
// "sihirbaz" hedefinde akış /sonuc'ta devam eder (üretim ya da giriş
|
||||
// paneli); "kaydedildi" toast'ı akış bitti izlenimi verirdi.
|
||||
if (!duzenle && !bekleyenVar && hedef !== "sihirbaz") {
|
||||
toast.success("Tercihlerin kaydedildi.");
|
||||
}
|
||||
// Düzenlemede replace: aynı /sonuc sayfasında sira/tur değişince geçmiş
|
||||
@@ -363,11 +381,13 @@ function KapıIcerik({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
duzenle ? profilKapisiniKapat() : adimaGec("giris")
|
||||
// Doğrudan sıralama adımıyla açıldıysa tanıtım adımı hiç
|
||||
// görülmedi; "Geri" yerine kapıyı kapatmak beklenen davranış.
|
||||
duzenle || dogrudanSira ? profilKapisiniKapat() : adimaGec("giris")
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{duzenle ? "Vazgeç" : "Geri"}
|
||||
{duzenle || dogrudanSira ? "Vazgeç" : "Geri"}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -399,7 +419,11 @@ function KapıIcerik({
|
||||
? "Tercihlerimi güncelle"
|
||||
: bekleyen
|
||||
? "Kaydet ve programı ekle"
|
||||
: "Tercihlerimi kaydet"
|
||||
: hedef === "sihirbaz"
|
||||
? girisli
|
||||
? "Yapay Zeka listemi kur (3 kredi)"
|
||||
: "Ücretsiz tablonu gör"
|
||||
: "Tercihlerimi kaydet"
|
||||
}
|
||||
onTamamla={secimleriTamamla}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||
import type { Program } from "@/lib/db";
|
||||
|
||||
export function programEtkinSira(program: Program): number | null {
|
||||
/** Taban sıra sütunlarının yapısal alt kümesi: hem DB programı hem de
|
||||
* rapordaki kaydedilmiş sıra geçmişi (bkz. raporSiraSerisi) bu tipe uyar. */
|
||||
export type SiraSerisi = Pick<
|
||||
Program,
|
||||
"sira2021" | "sira2022" | "sira2023" | "sira2024" | "sira2025"
|
||||
>;
|
||||
|
||||
/** Rapordaki [2021..2025] dizisini sütunlu seriye çevirir. */
|
||||
export function raporSiraSerisi(gecmis?: (number | null)[]): SiraSerisi {
|
||||
const g = gecmis ?? [];
|
||||
return {
|
||||
sira2021: g[0] ?? null,
|
||||
sira2022: g[1] ?? null,
|
||||
sira2023: g[2] ?? null,
|
||||
sira2024: g[3] ?? null,
|
||||
sira2025: g[4] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function programEtkinSira(program: SiraSerisi): number | null {
|
||||
return program.sira2025 ?? program.sira2024;
|
||||
}
|
||||
|
||||
/** 2021–2025 taban sıralamasını sonuç listesindeki kompakt çizgiyle gösterir. */
|
||||
export function ProgramSiraGecmisi({ program }: { program: Program }) {
|
||||
export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
|
||||
const yillar = [
|
||||
[2021, program.sira2021],
|
||||
[2022, program.sira2022],
|
||||
@@ -61,7 +80,7 @@ export function ProgramSiraGecmisi({ program }: { program: Program }) {
|
||||
}
|
||||
|
||||
/** Son iki yılın tabanı %10'dan fazla değiştiğinde yönü işaretler. */
|
||||
export function ProgramTabanTrendi({ program }: { program: Program }) {
|
||||
export function ProgramTabanTrendi({ program }: { program: SiraSerisi }) {
|
||||
if (program.sira2025 == null || program.sira2024 == null) return null;
|
||||
|
||||
const oran = (program.sira2024 - program.sira2025) / program.sira2024;
|
||||
|
||||
221
src/components/program-netleri.tsx
Normal file
221
src/components/program-netleri.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
// Program satırındaki "netler" butonu: YÖK Atlas'ın "yerleşen son kişinin
|
||||
// netleri" verisini (data/yokatlas.db → netler tablosu, /api/netler) yıl
|
||||
// sütunlu kompakt bir modalde gösterir. Kadın-erkek dağılımı bilinçli olarak
|
||||
// yok: YÖK Atlas 2026 SPA'sına geçerken cinsiyet panelini yayından kaldırdı
|
||||
// (bkz. scripts/detay.ts).
|
||||
|
||||
import { useState } from "react";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import type { ProgramNetSatiri } from "@/lib/db";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
// Ders sırası ÖSYM oturum sırasıyla; max = testteki soru sayısı.
|
||||
const DERSLER: {
|
||||
alan: keyof Omit<ProgramNetSatiri, "yil" | "puanTuru" | "tabanPuan" | "obp">;
|
||||
etiket: string;
|
||||
max: number;
|
||||
}[] = [
|
||||
{ alan: "tytTrkNet", etiket: "TYT Türkçe", max: 40 },
|
||||
{ alan: "tytSosNet", etiket: "TYT Sosyal Bilimler", max: 20 },
|
||||
{ alan: "tytMatNet", etiket: "TYT Matematik", max: 40 },
|
||||
{ alan: "tytFenNet", etiket: "TYT Fen Bilimleri", max: 20 },
|
||||
{ alan: "aytMatNet", etiket: "AYT Matematik", max: 40 },
|
||||
{ alan: "aytFizNet", etiket: "AYT Fizik", max: 14 },
|
||||
{ alan: "aytKimNet", etiket: "AYT Kimya", max: 13 },
|
||||
{ alan: "aytBioNet", etiket: "AYT Biyoloji", max: 13 },
|
||||
{ alan: "aytTdeNet", etiket: "AYT Türk Dili ve Edebiyatı", max: 24 },
|
||||
{ alan: "aytTrh1Net", etiket: "AYT Tarih-1", max: 10 },
|
||||
{ alan: "aytCog1Net", etiket: "AYT Coğrafya-1", max: 6 },
|
||||
{ alan: "aytTrh2Net", etiket: "AYT Tarih-2", max: 11 },
|
||||
{ alan: "aytCog2Net", etiket: "AYT Coğrafya-2", max: 11 },
|
||||
{ alan: "aytDinNet", etiket: "AYT Din Kültürü", max: 6 },
|
||||
{ alan: "aytFelNet", etiket: "AYT Felsefe Grubu", max: 12 },
|
||||
{ alan: "ydtYdilNet", etiket: "YDT Yabancı Dil", max: 80 },
|
||||
];
|
||||
|
||||
const net = (n: number) =>
|
||||
n.toLocaleString("tr-TR", { maximumFractionDigits: 2 });
|
||||
|
||||
type Durum = "bekliyor" | "yukleniyor" | "hazir" | "hata";
|
||||
|
||||
export function ProgramNetleriButonu({
|
||||
programId,
|
||||
isim,
|
||||
universite,
|
||||
className = "",
|
||||
}: {
|
||||
programId: string;
|
||||
isim: string;
|
||||
universite?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [durum, setDurum] = useState<Durum>("bekliyor");
|
||||
const [netler, setNetler] = useState<ProgramNetSatiri[]>([]);
|
||||
|
||||
async function yukle() {
|
||||
setDurum("yukleniyor");
|
||||
try {
|
||||
const res = await fetch(`/api/netler?id=${programId}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const veri = (await res.json()) as { netler: ProgramNetSatiri[] };
|
||||
setNetler(veri.netler);
|
||||
setDurum("hazir");
|
||||
} catch {
|
||||
setDurum("hata");
|
||||
}
|
||||
}
|
||||
|
||||
function acildi(acik: boolean) {
|
||||
if (!acik) return;
|
||||
olay("netler_goruntulendi");
|
||||
if (durum === "bekliyor" || durum === "hata") yukle();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={acildi}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title="Son yerleşenin netleri"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`inline-flex size-11 min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-primary hover:bg-primary/5 hover:text-primary active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`}
|
||||
>
|
||||
<ListChecks className="size-4" aria-hidden />
|
||||
<span className="sr-only">{isim} — son yerleşenin netleri</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="pr-10">Son yerleşenin netleri</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isim}
|
||||
{universite ? ` · ${universite}` : ""} — bölüme en düşük puanla
|
||||
yerleşen adayın sınav netleri (YÖK Atlas).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<NetlerIcerik durum={durum} netler={netler} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function NetlerIcerik({
|
||||
durum,
|
||||
netler,
|
||||
}: {
|
||||
durum: Durum;
|
||||
netler: ProgramNetSatiri[];
|
||||
}) {
|
||||
if (durum === "yukleniyor" || durum === "bekliyor") {
|
||||
return (
|
||||
<div className="space-y-2" aria-hidden>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-6 animate-pulse rounded-md bg-slate-100" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (durum === "hata") {
|
||||
return (
|
||||
<p className="py-4 text-center text-sm text-slate-500">
|
||||
Netler yüklenemedi. Modalı kapatıp yeniden açmayı dene.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (netler.length === 0) {
|
||||
return (
|
||||
<p className="py-4 text-center text-sm text-slate-500">
|
||||
Bu program için YÖK Atlas'ta net verisi yayınlanmamış.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const dersler = DERSLER.filter((d) =>
|
||||
netler.some((y) => y[d.alan] != null),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="-mx-1 overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-9">Ders</TableHead>
|
||||
{netler.map((y) => (
|
||||
<TableHead key={y.yil} className="h-9 text-right tabular-nums">
|
||||
{y.yil}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dersler.map((d) => (
|
||||
<TableRow key={d.alan}>
|
||||
<TableCell className="py-2 text-xs text-slate-600">
|
||||
{d.etiket}
|
||||
<span className="text-slate-400"> / {d.max}</span>
|
||||
</TableCell>
|
||||
{netler.map((y) => {
|
||||
const deger = y[d.alan];
|
||||
return (
|
||||
<TableCell
|
||||
key={y.yil}
|
||||
className="py-2 text-right text-xs tabular-nums"
|
||||
>
|
||||
{deger != null ? net(deger) : "—"}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow>
|
||||
<TableCell className="py-2 text-xs font-medium text-slate-600">
|
||||
OBP
|
||||
</TableCell>
|
||||
{netler.map((y) => (
|
||||
<TableCell
|
||||
key={y.yil}
|
||||
className="py-2 text-right text-xs tabular-nums text-slate-500"
|
||||
>
|
||||
{y.obp != null ? net(y.obp) : "—"}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="py-2 text-xs font-semibold text-slate-700">
|
||||
Yerleşme puanı
|
||||
</TableCell>
|
||||
{netler.map((y) => (
|
||||
<TableCell
|
||||
key={y.yil}
|
||||
className="py-2 text-right text-xs font-semibold tabular-nums text-slate-700"
|
||||
>
|
||||
{y.tabanPuan != null ? net(y.tabanPuan) : "—"}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/lib/db";
|
||||
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
||||
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
|
||||
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
|
||||
import {
|
||||
ProgramSiraGecmisi,
|
||||
ProgramTabanTrendi,
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
||||
import { ProgramNetleriButonu } from "@/components/program-netleri";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
|
||||
const DILIMLER: {
|
||||
@@ -183,8 +184,8 @@ export function ProgramTablosu({
|
||||
Kontenjan
|
||||
</TableHead>
|
||||
<TableHead className="text-right">Taban sıra</TableHead>
|
||||
<TableHead className="w-16 text-right">
|
||||
<span className="sr-only">Listeye ekle</span>
|
||||
<TableHead className="w-24 text-right">
|
||||
<span className="sr-only">Netler ve listeye ekle</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -226,22 +227,6 @@ export function ProgramTablosu({
|
||||
);
|
||||
}
|
||||
|
||||
// /universite/[slug] sayfa slug'ı: harita.ts'teki uniAdiNormalize ile aynı
|
||||
// kural (sondaki "(İL)" eki atılır) — client bundle'a harita verisi çekmemek
|
||||
// için burada tekrarlanır. Katalog haritası DB'deki tüm universite
|
||||
// değerlerinden kurulduğu için bu slug her zaman var olan bir sayfaya çıkar.
|
||||
function uniSayfaSlug(ad: string): string {
|
||||
return turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim());
|
||||
}
|
||||
|
||||
// /bolum/[slug] sayfa slug'ı: katalog.ts'teki bolumSlugFromIsim ile aynı kural
|
||||
// (bolumBazAdi + turkishSlugify). Bölüm haritası DB'deki tüm isim
|
||||
// değerlerinden kurulduğu için bu slug da her zaman var olan bir sayfaya çıkar
|
||||
// (bkz. liste-cekmecesi'ndeki bolumHref).
|
||||
function bolumSayfaSlug(isim: string): string {
|
||||
return turkishSlugify(bolumBazAdi(isim));
|
||||
}
|
||||
|
||||
function ProgramSatiri({
|
||||
program: p,
|
||||
adaySira,
|
||||
@@ -336,13 +321,21 @@ function ProgramSatiri({
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<ProgramEkleButonu
|
||||
program={p}
|
||||
kaynak="sonuc"
|
||||
adaySira={adaySira}
|
||||
adayTur={tur}
|
||||
className="size-8 min-h-8 min-w-8"
|
||||
/>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProgramNetleriButonu
|
||||
programId={p.id}
|
||||
isim={p.isim}
|
||||
universite={p.universite}
|
||||
className="size-8 min-h-8 min-w-8"
|
||||
/>
|
||||
<ProgramEkleButonu
|
||||
program={p}
|
||||
kaynak="sonuc"
|
||||
adaySira={adaySira}
|
||||
adayTur={tur}
|
||||
className="size-8 min-h-8 min-w-8"
|
||||
/>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
"use client";
|
||||
|
||||
// 24'lük listenin gövdesi. Program adı, 2024/2025 tabanları ve ham trend
|
||||
// herkese görünür. Yapay Zeka gerekçesi ile kişisel risk, kilitliBaslangic sonrasında
|
||||
// ücretli katman olarak ayrılır; ücretli metin sunucuda zaten maskelidir.
|
||||
// 24'lük listenin gövdesi. Görsel dil sonuç sayfasındaki ham program
|
||||
// tablosuyla birebir aynıdır (bkz. program-tablosu.tsx): aynı beyaz kart,
|
||||
// aynı satır düzeni, aynı "Son 5 yıl" / "Taban sıra" sütunları. Buradaki fark
|
||||
// içerik: satır sırası tercih numarası, risk sütunu Yapay Zeka'dan gelir ve
|
||||
// satır açıldığında gerekçe/risk notu görünür. Program adı ve tabanlar herkese
|
||||
// açık; Yapay Zeka katmanı kilitliBaslangic sonrasında ücretlidir (metin
|
||||
// sunucuda zaten maskelidir).
|
||||
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Lock,
|
||||
MapPin,
|
||||
Minus,
|
||||
Rocket,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, Lock, MapPin, Rocket, Scale, ShieldCheck } from "lucide-react";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import {
|
||||
ProgramSiraGecmisi,
|
||||
ProgramTabanTrendi,
|
||||
programEtkinSira,
|
||||
raporSiraSerisi,
|
||||
} from "@/components/program-liste-verileri";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import {
|
||||
RISK_ETIKET,
|
||||
@@ -31,105 +41,66 @@ export const DILIM_ETIKET: Record<string, string> = {
|
||||
garanti: "Garanti",
|
||||
};
|
||||
|
||||
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli
|
||||
const RISK_STIL: Record<
|
||||
RiskSeviyesi,
|
||||
{ nokta: string; kenar: string; icon: typeof Rocket }
|
||||
> = {
|
||||
riskli: { nokta: "bg-red-500", kenar: "border-l-red-500", icon: Rocket },
|
||||
"az-riskli": {
|
||||
nokta: "bg-amber-500",
|
||||
kenar: "border-l-amber-500",
|
||||
icon: Scale,
|
||||
},
|
||||
guvenli: {
|
||||
nokta: "bg-emerald-500",
|
||||
kenar: "border-l-emerald-500",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli.
|
||||
// Nokta hiçbir yerde tek başına durmaz; yanındaki etiket dar ekranda sr-only
|
||||
// olarak kalır (renk tek başına anlam taşımaz).
|
||||
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; icon: typeof Rocket }> = {
|
||||
riskli: { nokta: "bg-red-500", icon: Rocket },
|
||||
"az-riskli": { nokta: "bg-amber-500", icon: Scale },
|
||||
guvenli: { nokta: "bg-emerald-500", icon: ShieldCheck },
|
||||
};
|
||||
|
||||
const YILLAR = [2021, 2022, 2023, 2024, 2025];
|
||||
|
||||
type Trend = "zorlasiyor" | "kolaylasiyor" | "stabil";
|
||||
|
||||
/** Deterministik trend: ilk ve son bilinen taban sıralaması kıyaslanır.
|
||||
* Sıralama sayısı küçüldüyse bölüm zorlaşıyor demektir. */
|
||||
function trendYonu(gecmis?: (number | null)[]): Trend | null {
|
||||
const dolu = (gecmis ?? []).filter((s): s is number => s != null);
|
||||
if (dolu.length < 2) return null;
|
||||
const oran = dolu[dolu.length - 1] / dolu[0];
|
||||
if (oran < 0.88) return "zorlasiyor";
|
||||
if (oran > 1.14) return "kolaylasiyor";
|
||||
return "stabil";
|
||||
}
|
||||
|
||||
const TREND_GORUNUM: Record<
|
||||
Trend,
|
||||
{ icon: typeof Minus; etiket: string; renk: string }
|
||||
> = {
|
||||
zorlasiyor: {
|
||||
icon: TrendingUp,
|
||||
etiket: "Taban zorlaşıyor",
|
||||
renk: "text-red-500",
|
||||
},
|
||||
kolaylasiyor: {
|
||||
icon: TrendingDown,
|
||||
etiket: "Taban rahatlıyor",
|
||||
renk: "text-emerald-600",
|
||||
},
|
||||
stabil: { icon: Minus, etiket: "Taban stabil", renk: "text-slate-400" },
|
||||
};
|
||||
|
||||
/** Son 5 yılın taban sıralaması mini çizgisi (detay panelinde). */
|
||||
function TrendCizgisi({ gecmis }: { gecmis: (number | null)[] }) {
|
||||
const noktalar = YILLAR.map((yil, i) => ({ yil, sira: gecmis[i] })).filter(
|
||||
(n): n is { yil: number; sira: number } => n.sira != null,
|
||||
);
|
||||
if (noktalar.length < 2) return null;
|
||||
|
||||
const W = 150;
|
||||
const H = 40;
|
||||
const min = Math.min(...noktalar.map((n) => n.sira));
|
||||
const max = Math.max(...noktalar.map((n) => n.sira));
|
||||
const aralik = Math.max(1, max - min);
|
||||
// Küçük sıralama = daha zor bölüm = çizgide yukarıda
|
||||
const nokta = (i: number, sira: number) => ({
|
||||
x: 6 + (i / (noktalar.length - 1)) * (W - 12),
|
||||
y: 8 + ((sira - min) / aralik) * (H - 16),
|
||||
});
|
||||
const path = noktalar
|
||||
.map((n, i) => {
|
||||
const { x, y } = nokta(i, n.sira);
|
||||
return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
const son = nokta(noktalar.length - 1, noktalar[noktalar.length - 1].sira);
|
||||
// Sütun sayısı: # · Program · Son 5 yıl · Risk · Taban sıra · detay oku
|
||||
const SUTUN_SAYISI = 6;
|
||||
|
||||
/**
|
||||
* Açık satırın detay bloğu: dilim/risk başlığı + gerekçe ve risk/trend
|
||||
* kartları. /sonuc'taki girişsiz tadımlık satırı da (tadimlik-satiri.tsx)
|
||||
* aynı bileşeni kullanır — "demo değil ürünün kendisi" hissi kopyayla değil
|
||||
* tek kaynakla sağlanır.
|
||||
*/
|
||||
export function RaporSatirDetayi({
|
||||
dilim,
|
||||
risk,
|
||||
unitur,
|
||||
gerekce,
|
||||
riskNotu,
|
||||
trendOzeti,
|
||||
}: {
|
||||
dilim: string;
|
||||
risk: RiskSeviyesi;
|
||||
unitur?: string | null;
|
||||
gerekce: string;
|
||||
riskNotu: string;
|
||||
trendOzeti: string;
|
||||
}) {
|
||||
const stil = RISK_STIL[risk];
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="h-10 w-37.5"
|
||||
role="img"
|
||||
aria-label={`Taban sıralaması ${noktalar[0].yil}: ${noktalar[0].sira.toLocaleString("tr-TR")}, ${noktalar[noktalar.length - 1].yil}: ${noktalar[noktalar.length - 1].sira.toLocaleString("tr-TR")}`}
|
||||
>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="oklch(0.623 0.188 259.8)"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx={son.x} cy={son.y} r={3} fill="oklch(0.623 0.188 259.8)" />
|
||||
</svg>
|
||||
<span className="text-xs text-slate-500">
|
||||
{noktalar[0].yil}: ~{noktalar[0].sira.toLocaleString("tr-TR")}
|
||||
<br />
|
||||
{noktalar[noktalar.length - 1].yil}: ~
|
||||
{noktalar[noktalar.length - 1].sira.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
</div>
|
||||
<>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<stil.icon className="size-3.5" aria-hidden />
|
||||
<span className="font-medium">
|
||||
{DILIM_ETIKET[dilim]} · {RISK_ETIKET[risk]}
|
||||
</span>
|
||||
{unitur ? <span>· {unitur}</span> : null}
|
||||
</div>
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<dt className="text-xs font-semibold text-slate-500">
|
||||
Neden listede?
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">{gerekce}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<dt className="text-xs font-semibold text-slate-500">
|
||||
Risk ve Yapay Zeka yorumu
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">
|
||||
{riskNotu} {trendOzeti}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,7 +122,7 @@ export function RaporListesi({
|
||||
onHaritadaGor?: (il: string | null, sira: number) => void;
|
||||
}) {
|
||||
const [acik, setAcik] = useState<Set<number>>(new Set());
|
||||
const satirRefs = useRef(new Map<number, HTMLLIElement>());
|
||||
const satirRefs = useRef(new Map<number, HTMLTableRowElement>());
|
||||
|
||||
// Haritadan gelen seçim: satırı aç ve görünür alana kaydır
|
||||
useEffect(() => {
|
||||
@@ -174,15 +145,6 @@ export function RaporListesi({
|
||||
|
||||
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
|
||||
|
||||
// Dilim dağılımı: liste hayal diliminden başladığı için ilk ekran hep
|
||||
// kırmızı görünür; özet, dengenin sağlıklı olduğunu tek bakışta gösterir.
|
||||
const dilimSayilari = rapor.tercihler.reduce(
|
||||
(t, x) => {
|
||||
t[x.dilim] = (t[x.dilim] ?? 0) + 1;
|
||||
return t;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
const iller = new Set(
|
||||
rapor.tercihler
|
||||
.map((t) => rapor.programlar[t.programId]?.il)
|
||||
@@ -190,252 +152,232 @@ export function RaporListesi({
|
||||
);
|
||||
const tekIl = iller.size === 1 ? [...iller][0] : null;
|
||||
|
||||
const satir = (
|
||||
t: RaporSonuc["tercihler"][number],
|
||||
index: number,
|
||||
) => {
|
||||
const satir = (t: RaporSonuc["tercihler"][number], index: number) => {
|
||||
const p = rapor.programlar[t.programId];
|
||||
const analizKilitli = index >= kilitIdx;
|
||||
const risk =
|
||||
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
|
||||
dilimdenRisk(t.dilim);
|
||||
const stil = RISK_STIL[risk];
|
||||
const trend = trendYonu(p?.siraGecmisi);
|
||||
const seri = raporSiraSerisi(p?.siraGecmisi);
|
||||
const taban = p?.efektifSira ?? programEtkinSira(seri);
|
||||
const acikMi = acik.has(t.sira);
|
||||
const sira2024 = p?.siraGecmisi?.[3] ?? null;
|
||||
const sira2025 = p?.siraGecmisi?.[4] ?? null;
|
||||
// Son iki yıldan biri eksikse satır başındaki trend rozeti çelişkili
|
||||
// okunur ("2025: —" ama "stabil") — rozet ancak iki yıl da doluysa çıkar;
|
||||
// uzun vadeli trend detaydaki 2021–2025 çizgisinde durur.
|
||||
const trendG =
|
||||
trend && sira2024 != null && sira2025 != null
|
||||
? TREND_GORUNUM[trend]
|
||||
: null;
|
||||
const devlet = p?.unitur === "DEVLET";
|
||||
|
||||
return (
|
||||
<li
|
||||
key={t.sira}
|
||||
ref={(el) => {
|
||||
if (el) satirRefs.current.set(t.sira, el);
|
||||
else satirRefs.current.delete(t.sira);
|
||||
}}
|
||||
className={`overflow-hidden rounded-xl border bg-white transition-[border-color,box-shadow] duration-200 hover:border-slate-300 hover:shadow-xs ${
|
||||
analizKilitli ? "border-slate-200" : `border-l-4 ${stil.kenar}`
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<Fragment key={t.sira}>
|
||||
<TableRow
|
||||
ref={(el) => {
|
||||
if (el) satirRefs.current.set(t.sira, el);
|
||||
else satirRefs.current.delete(t.sira);
|
||||
}}
|
||||
onClick={() => tiklandi(t.sira)}
|
||||
aria-expanded={acikMi}
|
||||
className="grid min-h-16 w-full cursor-pointer grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 px-3 py-2.5 text-left xl:grid-cols-[auto_minmax(220px,1fr)_92px_92px_112px_82px_auto] xl:px-4"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
|
||||
{t.sira}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
{p?.universite ? (
|
||||
<UniLogo ad={p.universite} boy="sm" className="hidden sm:flex" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{p?.isim ?? t.programId}
|
||||
<TableCell className="pl-4 pr-0">
|
||||
<span className="flex size-7 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
|
||||
{t.sira}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-slate-500">
|
||||
{p?.universite}
|
||||
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-3 text-[11px] tabular-nums text-slate-500 xl:hidden">
|
||||
<span>2024: {sira2024?.toLocaleString("tr-TR") ?? "—"}</span>
|
||||
<span>2025: {sira2025?.toLocaleString("tr-TR") ?? "—"}</span>
|
||||
{trendG ? <span className={trendG.renk}>{trendG.etiket}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden text-right text-xs tabular-nums text-slate-600 xl:block">
|
||||
{sira2024?.toLocaleString("tr-TR") ?? "—"}
|
||||
</span>
|
||||
<span className="hidden text-right text-xs font-semibold tabular-nums text-slate-900 xl:block">
|
||||
{sira2025?.toLocaleString("tr-TR") ?? "—"}
|
||||
</span>
|
||||
<span className="hidden items-center gap-1.5 text-xs xl:flex">
|
||||
{trendG ? (
|
||||
<>
|
||||
<trendG.icon className={`size-4 shrink-0 ${trendG.renk}`} aria-hidden />
|
||||
<span className={trendG.renk}>
|
||||
{trend === "zorlasiyor"
|
||||
? "Zorlaşıyor"
|
||||
: trend === "kolaylasiyor"
|
||||
? "Rahatlıyor"
|
||||
: "Stabil"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-0 w-full">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
{p?.universite ? <UniLogo ad={p.universite} boy="sm" /> : null}
|
||||
<span className="min-w-0 flex-1">
|
||||
{p ? (
|
||||
<Link
|
||||
href={`/bolum/${bolumSayfaSlug(p.isim)}`}
|
||||
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title={`${p.isim} bölüm sayfası`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{p.isim}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{t.programId}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
|
||||
{p?.unitur ? (
|
||||
<span
|
||||
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
|
||||
devlet
|
||||
? "bg-slate-100 text-slate-600"
|
||||
: "bg-violet-50 text-violet-600"
|
||||
}`}
|
||||
>
|
||||
{devlet ? "Devlet" : "Vakıf"}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate">
|
||||
{p?.universite ? (
|
||||
<Link
|
||||
href={`/universite/${uniSayfaSlug(p.universite)}`}
|
||||
className="hover:text-primary hover:underline"
|
||||
title={`${p.universite} taban puanları sayfası`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{p.universite}
|
||||
</Link>
|
||||
) : null}
|
||||
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-slate-400">Veri eksik</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="hidden items-center gap-1.5 text-xs font-medium xl:flex">
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-right text-xs md:table-cell">
|
||||
<ProgramSiraGecmisi program={seri} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium">
|
||||
{analizKilitli ? (
|
||||
<>
|
||||
<Lock className="size-3.5 text-slate-400" aria-hidden />
|
||||
<span className="text-slate-500">Pakette</span>
|
||||
</>
|
||||
<span className="inline-flex items-center gap-1.5 text-slate-500">
|
||||
<Lock className="size-3.5 shrink-0 text-slate-400" aria-hidden />
|
||||
<span className="sr-only sm:not-sr-only">Pakette</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={`size-2.5 rounded-full ${stil.nokta}`} aria-hidden />
|
||||
<span>{RISK_ETIKET[risk]}</span>
|
||||
</>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={`size-2.5 shrink-0 rounded-full ${stil.nokta}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only sm:not-sr-only">
|
||||
{RISK_ETIKET[risk]}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`size-4 shrink-0 text-slate-400 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums text-slate-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ProgramTabanTrendi program={seri} />
|
||||
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={acikMi}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
tiklandi(t.sira);
|
||||
}}
|
||||
className="inline-flex size-8 min-h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-primary hover:bg-primary/5 hover:text-primary active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-4 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{p?.isim ?? t.programId} — Yapay Zeka değerlendirmesi
|
||||
</span>
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{acikMi ? (
|
||||
<div className="border-t border-slate-100 bg-slate-50/50 px-4 py-4">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={SUTUN_SAYISI}
|
||||
className="whitespace-normal bg-slate-50/60 px-4 py-4"
|
||||
>
|
||||
{analizKilitli ? (
|
||||
<>
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
<span className="font-medium">Kişisel analiz pakette</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<stil.icon className="size-3.5" aria-hidden />
|
||||
<span className="font-medium">
|
||||
{DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{p?.unitur ? <span>· {p.unitur}</span> : null}
|
||||
</div>
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<dt className="text-xs font-semibold text-slate-500">
|
||||
2021–2025 taban trendi
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">
|
||||
{p?.siraGecmisi ? (
|
||||
<TrendCizgisi gecmis={p.siraGecmisi} />
|
||||
) : (
|
||||
"Yeterli veri yok."
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
{analizKilitli ? (
|
||||
<div className="flex flex-col justify-center rounded-lg border border-dashed border-slate-300 bg-white p-3 sm:col-span-2">
|
||||
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
Yapay Zeka karar desteği
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-slate-600">
|
||||
Bu bölümün neden listende olduğunu ve sıralamana özel yerleşme
|
||||
riskini paketle gör.
|
||||
</dd>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<dt className="text-xs font-semibold text-slate-500">
|
||||
Neden listede?
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
|
||||
<span className="font-medium">Kişisel analiz pakette</span>
|
||||
{p?.unitur ? <span>· {p.unitur}</span> : null}
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<dt className="text-xs font-semibold text-slate-500">
|
||||
Risk ve Yapay Zeka yorumu
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">
|
||||
{t.riskNotu} {t.trendOzeti}
|
||||
</dd>
|
||||
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-3">
|
||||
<p className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
Yapay Zeka karar desteği
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Bu bölümün neden listende olduğunu ve sıralamana özel
|
||||
yerleşme riskini paketle gör.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<RaporSatirDetayi
|
||||
dilim={t.dilim}
|
||||
risk={risk}
|
||||
unitur={p?.unitur}
|
||||
gerekce={t.gerekce}
|
||||
riskNotu={t.riskNotu}
|
||||
trendOzeti={t.trendOzeti}
|
||||
/>
|
||||
)}
|
||||
</dl>
|
||||
{onHaritadaGor && p?.il ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onHaritadaGor(p.il, t.sira)}
|
||||
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium text-primary transition-colors duration-200 hover:text-primary/80"
|
||||
>
|
||||
<MapPin className="size-3.5" aria-hidden />
|
||||
Haritada gör
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{onHaritadaGor && p?.il ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onHaritadaGor(p.il, t.sira)}
|
||||
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium text-primary transition-colors duration-200 hover:text-primary/80"
|
||||
>
|
||||
<MapPin className="size-3.5" aria-hidden />
|
||||
Haritada gör
|
||||
</button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</li>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="font-heading text-lg font-bold">24 tercihin ve taban verileri</h2>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Satıra dokunarak 2021–2025 trendini inceleyebilirsin.
|
||||
</p>
|
||||
</div>
|
||||
{kilitIdx < rapor.tercihler.length ? (
|
||||
<p className="text-xs font-medium text-emerald-700">
|
||||
Programlar ve ham veriler ücretsiz
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs font-medium">
|
||||
{(
|
||||
[
|
||||
["hayal", "bg-red-500"],
|
||||
["dengeli", "bg-amber-500"],
|
||||
["garanti", "bg-emerald-500"],
|
||||
] as const
|
||||
).map(([dilim, nokta]) => (
|
||||
<span
|
||||
key={dilim}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-slate-700"
|
||||
>
|
||||
<span className={`size-2 rounded-full ${nokta}`} aria-hidden />
|
||||
{dilimSayilari[dilim] ?? 0} {DILIM_ETIKET[dilim].toLocaleLowerCase("tr-TR")}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-slate-400">
|
||||
Liste hayal diliminden başlar; kırmızı satırlar plana dahildir.
|
||||
</span>
|
||||
</div>
|
||||
{kilitIdx < rapor.tercihler.length ? (
|
||||
<p className="text-right text-xs font-medium text-emerald-700">
|
||||
Programlar ve ham veriler ücretsiz
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tekIl ? (
|
||||
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
Listenin 24 tercihi de tek şehirde (
|
||||
{tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak
|
||||
için birkaç farklı şehir eklemeyi düşünebilirsin.
|
||||
{tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak için
|
||||
birkaç farklı şehir eklemeyi düşünebilirsin.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 hidden grid-cols-[auto_minmax(220px,1fr)_92px_92px_112px_82px_auto] gap-x-3 px-4 text-[11px] font-semibold uppercase tracking-wide text-slate-500 xl:grid">
|
||||
<span className="w-7">#</span>
|
||||
<span>Program</span>
|
||||
<span className="text-right">2024 taban</span>
|
||||
<span className="text-right">2025 taban</span>
|
||||
<span>Ham trend</span>
|
||||
<span>Yapay Zeka risk</span>
|
||||
<span className="w-4" />
|
||||
<div className="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8 pl-4 pr-0">#</TableHead>
|
||||
<TableHead>Program</TableHead>
|
||||
<TableHead className="hidden text-right md:table-cell">
|
||||
Son 5 yıl
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<span className="hidden sm:inline">Yapay Zeka </span>Risk
|
||||
</TableHead>
|
||||
<TableHead className="text-right">Taban sıra</TableHead>
|
||||
<TableHead className="w-12 pr-4 text-right">
|
||||
<span className="sr-only">Yapay Zeka değerlendirmesi</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rapor.tercihler.map((t, index) => (
|
||||
<Fragment key={t.sira}>
|
||||
{index === kilitIdx && kilitOverlay ? (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={SUTUN_SAYISI}
|
||||
className="whitespace-normal border-y border-orange-200 bg-orange-50/70 p-5"
|
||||
>
|
||||
{kilitOverlay}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{satir(t, index)}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<ol className="mt-2 flex flex-col gap-2">
|
||||
{rapor.tercihler.map((t, index) => (
|
||||
<Fragment key={t.sira}>
|
||||
{index === kilitIdx && kilitOverlay ? (
|
||||
<li className="my-2 list-none rounded-2xl border border-orange-200 bg-orange-50/70 p-5">
|
||||
{kilitOverlay}
|
||||
</li>
|
||||
) : null}
|
||||
{satir(t, index)}
|
||||
</Fragment>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function Sayfalama({
|
||||
{sayfa > 1
|
||||
? gecis(
|
||||
sayfa - 1,
|
||||
"inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
|
||||
"inline-flex h-9 items-center gap-1 rounded-full border border-slate-200 bg-white px-3.5 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
|
||||
<>
|
||||
<ChevronLeft className="size-4" aria-hidden />
|
||||
Önceki
|
||||
@@ -107,8 +107,8 @@ export function Sayfalama({
|
||||
gecis(
|
||||
n,
|
||||
n === sayfa
|
||||
? "inline-flex h-9 min-w-9 items-center justify-center rounded-lg bg-primary px-2 text-sm font-semibold text-primary-foreground"
|
||||
: "inline-flex h-9 min-w-9 items-center justify-center rounded-lg border border-slate-200 bg-white px-2 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
|
||||
? "inline-flex h-9 min-w-9 items-center justify-center rounded-full bg-primary px-2 text-sm font-semibold text-primary-foreground"
|
||||
: "inline-flex h-9 min-w-9 items-center justify-center rounded-full border border-slate-200 bg-white px-2 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
|
||||
n,
|
||||
{ aktif: n === sayfa },
|
||||
)
|
||||
@@ -117,7 +117,7 @@ export function Sayfalama({
|
||||
{sayfa < toplamSayfa
|
||||
? gecis(
|
||||
sayfa + 1,
|
||||
"inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
|
||||
"inline-flex h-9 items-center gap-1 rounded-full border border-slate-200 bg-white px-3.5 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
|
||||
<>
|
||||
Sonraki
|
||||
<ChevronRight className="size-4" aria-hidden />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-full border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -22,16 +22,14 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
"h-9 gap-1.5 px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 px-3 text-[0.8rem] has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-12 gap-2 px-7 text-base has-data-[icon=inline-end]:pr-6 has-data-[icon=inline-start]:pl-6",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-7",
|
||||
"icon-lg": "size-12",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -28,7 +28,7 @@ function PromptSuggestion({
|
||||
<Button
|
||||
variant={variant || "outline"}
|
||||
size={size || "lg"}
|
||||
className={cn("rounded-full", className)}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -42,7 +42,7 @@ function PromptSuggestion({
|
||||
variant={variant || "ghost"}
|
||||
size={size || "sm"}
|
||||
className={cn(
|
||||
"w-full cursor-pointer justify-start rounded-xl py-2",
|
||||
"w-full cursor-pointer justify-start py-2",
|
||||
"hover:bg-accent",
|
||||
className
|
||||
)}
|
||||
@@ -63,7 +63,7 @@ function PromptSuggestion({
|
||||
variant={variant || "ghost"}
|
||||
size={size || "sm"}
|
||||
className={cn(
|
||||
"w-full cursor-pointer justify-start gap-0 rounded-xl py-2",
|
||||
"w-full cursor-pointer justify-start gap-0 py-2",
|
||||
"hover:bg-accent",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
programEtkinSira,
|
||||
} from "@/components/program-liste-verileri";
|
||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
||||
import { ProgramNetleriButonu } from "@/components/program-netleri";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -56,8 +57,8 @@ export function UniversiteProgramTablosu({
|
||||
Kontenjan
|
||||
</TableHead>
|
||||
<TableHead className="text-right">Taban sıra</TableHead>
|
||||
<TableHead className="w-14 text-right">
|
||||
<span className="sr-only">Listeye ekle</span>
|
||||
<TableHead className="w-24 text-right">
|
||||
<span className="sr-only">Netler ve listeye ekle</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -108,11 +109,18 @@ export function UniversiteProgramTablosu({
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="pr-3 text-right">
|
||||
<ProgramEkleButonu
|
||||
program={p}
|
||||
kaynak="universite"
|
||||
className="size-8 min-h-8 min-w-8"
|
||||
/>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProgramNetleriButonu
|
||||
programId={p.id}
|
||||
isim={p.isim.trim()}
|
||||
className="size-8 min-h-8 min-w-8"
|
||||
/>
|
||||
<ProgramEkleButonu
|
||||
program={p}
|
||||
kaynak="universite"
|
||||
className="size-8 min-h-8 min-w-8"
|
||||
/>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
54
src/components/use-gorunum-olayi.ts
Normal file
54
src/components/use-gorunum-olayi.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import { olay, type OlayAdi, type Ozellikler } from "@/lib/analitik";
|
||||
|
||||
/**
|
||||
* Bir elemanın viewport'a ilk girişinde analitik olayı atan ref hook'u.
|
||||
* Gösterim olayları (hazir_panel_goruntulendi, kilit_goruntulendi vb.)
|
||||
* oturum başına en fazla 1 kez sayılmalı; tekilleştirme sessionStorage
|
||||
* anahtarıyla yapılır. IntersectionObserver yoksa olay hiç atılmaz —
|
||||
* "görüntülendi" ölçümünde görünürlüğü doğrulayamadığımız cihazı saymak
|
||||
* veriyi şişirir.
|
||||
*/
|
||||
export function useGorunumOlayi<T extends HTMLElement = HTMLElement>(
|
||||
ad: OlayAdi,
|
||||
ozellikler?: Ozellikler,
|
||||
/** Aynı olayın farklı yüzeyleri için tekilleştirme eki ("satir", "kart"). */
|
||||
anahtarEki?: string,
|
||||
): RefObject<T | null> {
|
||||
const ref = useRef<T>(null);
|
||||
// Özellikler her render sonrası tazelenir; observer effect'i yalnızca
|
||||
// ad/anahtar değişiminde kurulur (deps'e obje koymamak için ref).
|
||||
const ozelliklerRef = useRef(ozellikler);
|
||||
useEffect(() => {
|
||||
ozelliklerRef.current = ozellikler;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || typeof IntersectionObserver === "undefined") return;
|
||||
|
||||
const anahtar = `kolaytercih.olay.${ad}${anahtarEki ? `.${anahtarEki}` : ""}`;
|
||||
try {
|
||||
if (window.sessionStorage.getItem(anahtar)) return;
|
||||
} catch {
|
||||
// Storage engelliyse tekilleştirmeden vazgeç, olay yine atılabilir
|
||||
}
|
||||
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((e) => e.isIntersecting)) return;
|
||||
io.disconnect();
|
||||
try {
|
||||
window.sessionStorage.setItem(anahtar, "1");
|
||||
} catch {
|
||||
// yut
|
||||
}
|
||||
olay(ad, ozelliklerRef.current);
|
||||
});
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [ad, anahtarEki]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Coins } from "lucide-react";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
|
||||
import { ListeSenkron } from "@/components/manuel-liste/liste-senkron";
|
||||
import { RybbitIdentify } from "@/components/rybbit-identify";
|
||||
import { SignOutButton } from "./sign-out-button";
|
||||
|
||||
@@ -29,6 +30,7 @@ export async function UserNav() {
|
||||
return (
|
||||
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
|
||||
<RybbitIdentify kullaniciId={null} />
|
||||
<ListeSenkron userId={null} />
|
||||
<ListemButonu />
|
||||
<Button
|
||||
asChild
|
||||
@@ -43,6 +45,7 @@ export async function UserNav() {
|
||||
return (
|
||||
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
|
||||
<RybbitIdentify kullaniciId={u.id} eposta={u.email} isim={u.name} />
|
||||
<ListeSenkron userId={u.id} />
|
||||
<ListemButonu />
|
||||
<Link
|
||||
href="/paket"
|
||||
|
||||
15
src/instrumentation.ts
Normal file
15
src/instrumentation.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Sunucu açılışında bir kez çalışır (Next instrumentation konvansiyonu).
|
||||
* Kredi-bitti hatırlatma taramasını başlatır — tek, sürekli çalışan
|
||||
* container'da harici cron yerine in-process zamanlayıcı yeterli.
|
||||
*/
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
if (process.env.NEXT_PHASE === "phase-production-build") return;
|
||||
// Migration akışı olmadığından sonradan eklenen tablolar açılışta güvenceye
|
||||
// alınır (additive DDL, idempotent) — mevcut prod volume'ü seed'den kopyalanmaz.
|
||||
const { semayiGuvenceyeAl } = await import("./lib/appdb/sema-guvence");
|
||||
await semayiGuvenceyeAl();
|
||||
const { krediHatirlatmaBaslat } = await import("./lib/kredi-hatirlatma");
|
||||
krediHatirlatmaBaslat();
|
||||
}
|
||||
@@ -19,11 +19,18 @@ export type OlayAdi =
|
||||
| "liste_olusturuldu"
|
||||
| "liste_revize"
|
||||
| "program_eklendi"
|
||||
| "netler_goruntulendi"
|
||||
| "danisman_sorusu"
|
||||
| "katalog_arama"
|
||||
| "odeme_baslatildi";
|
||||
| "odeme_baslatildi"
|
||||
| "hazir_panel_goruntulendi"
|
||||
| "giris_cta_tiklandi"
|
||||
| "kilit_goruntulendi"
|
||||
| "kredi_bitti"
|
||||
| "tadimlik_goruntulendi"
|
||||
| "kaydet_banner_goruntulendi";
|
||||
|
||||
type Ozellikler = Record<string, string | number>;
|
||||
export type Ozellikler = Record<string, string | number>;
|
||||
|
||||
interface RybbitApi {
|
||||
event: (ad: string, ozellikler?: Ozellikler) => void;
|
||||
|
||||
@@ -18,6 +18,13 @@ export const user = sqliteTable("user", {
|
||||
image: text("image"),
|
||||
creditBalance: integer("credit_balance").notNull().default(0),
|
||||
hasPaket: integer("has_paket", { mode: "boolean" }).notNull().default(false),
|
||||
// Kredi-bitti hatırlatma durum makinesi (kuruldu → gönderildi):
|
||||
// ilk INSUFFICIENT anı; kredi yüklenince sıfırlanır (hatırlatma iptal).
|
||||
krediBittiAt: integer("kredi_bitti_at", { mode: "timestamp" }),
|
||||
// Hatırlatma e-postası bir kez gönderilir, bu alan hiç sıfırlanmaz.
|
||||
krediHatirlatmaGonderildiAt: integer("kredi_hatirlatma_gonderildi_at", {
|
||||
mode: "timestamp",
|
||||
}),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
@@ -145,3 +152,47 @@ export const reports = sqliteTable("reports", {
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
|
||||
// Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları.
|
||||
// Anahtar: sıra kovası × puan türü × kategori ("genel" = kategorisiz fallback).
|
||||
// Anonim istek LLM'e asla gitmez; bu tablo salt-okunur servis edilir.
|
||||
// NOT: Yeni tablo/kolon eklerken sema-guvence.ts'teki DDL de güncellenmeli.
|
||||
export const tadimlikHavuzu = sqliteTable(
|
||||
"tadimlik_havuzu",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
kovaSlug: text("kova_slug").notNull(),
|
||||
tur: text("tur", { enum: ["say", "ea", "soz", "dil", "tyt"] }).notNull(),
|
||||
kategoriSlug: text("kategori_slug").notNull(),
|
||||
programId: text("program_id").notNull(),
|
||||
dilim: text("dilim", { enum: ["hayal", "dengeli", "garanti"] }).notNull(),
|
||||
gerekce: text("gerekce").notNull(),
|
||||
riskNotu: text("risk_notu").notNull(),
|
||||
trendOzeti: text("trend_ozeti").notNull(),
|
||||
// Üretim anındaki program snapshot'ı (isim, universite, il, unitur,
|
||||
// efektifSira, siraGecmisi) — render yokatlas sorgusu gerektirmesin diye.
|
||||
program: text("program", { mode: "json" }).notNull(),
|
||||
// Üretimde kullanılan temsili aday sırası (kovanın orta noktası).
|
||||
ornekSira: integer("ornek_sira").notNull(),
|
||||
uretimAt: integer("uretim_at", { mode: "timestamp" }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("tadimlik_anahtar").on(t.kovaSlug, t.tur, t.kategoriSlug),
|
||||
],
|
||||
);
|
||||
|
||||
// Manuel liste + sihirbaz profili — girişte localStorage'dan merge edilir
|
||||
// (çakışmada client kazanır, 24 sınırı korunur). reports ile aynı upsert deseni.
|
||||
export const savedLists = sqliteTable("saved_lists", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => user.id),
|
||||
// ManuelTercih[] (≤24, sıra korunur)
|
||||
items: text("items", { mode: "json" }).notNull(),
|
||||
// TercihProfili | null
|
||||
profil: text("profil", { mode: "json" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
|
||||
45
src/lib/appdb/sema-guvence.ts
Normal file
45
src/lib/appdb/sema-guvence.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { appDb } from "./index";
|
||||
|
||||
/**
|
||||
* Prod volume'ündeki app.db'ye sonradan eklenen tabloları güvenceye alır.
|
||||
*
|
||||
* Projede drizzle migration akışı yok: şema lokalde `pnpm db:push` ile
|
||||
* uygulanıp data/app.db seed olarak taşınıyor; ama docker-entrypoint.sh
|
||||
* app.db'yi yalnızca volume BOŞKEN kopyalar — mevcut prod volume'üne yeni
|
||||
* tablolar başka türlü gelmez. Burası sunucu açılışında (instrumentation)
|
||||
* bir kez koşar; yalnızca additive DDL içerir, idempotenttir.
|
||||
*
|
||||
* NOT: schema.ts'e tablo/kolon eklendiğinde buradaki DDL de güncellenmeli.
|
||||
*/
|
||||
export async function semayiGuvenceyeAl(): Promise<void> {
|
||||
await appDb.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS tadimlik_havuzu (
|
||||
id TEXT PRIMARY KEY,
|
||||
kova_slug TEXT NOT NULL,
|
||||
tur TEXT NOT NULL,
|
||||
kategori_slug TEXT NOT NULL,
|
||||
program_id TEXT NOT NULL,
|
||||
dilim TEXT NOT NULL,
|
||||
gerekce TEXT NOT NULL,
|
||||
risk_notu TEXT NOT NULL,
|
||||
trend_ozeti TEXT NOT NULL,
|
||||
program TEXT NOT NULL,
|
||||
ornek_sira INTEGER NOT NULL,
|
||||
uretim_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
await appDb.run(
|
||||
sql`CREATE UNIQUE INDEX IF NOT EXISTS tadimlik_anahtar ON tadimlik_havuzu (kova_slug, tur, kategori_slug)`,
|
||||
);
|
||||
await appDb.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS saved_lists (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL UNIQUE REFERENCES user(id),
|
||||
items TEXT NOT NULL,
|
||||
profil TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { magicLink } from "better-auth/plugins";
|
||||
import { appDb, schema } from "./appdb";
|
||||
import { grantCredits, DENEME_KREDISI } from "./credits";
|
||||
import { magicLinkEpostasi } from "./eposta";
|
||||
import { epostaGonder } from "./eposta-gonder";
|
||||
|
||||
async function sendMagicLinkEmail(email: string, url: string) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
@@ -16,25 +17,9 @@ async function sendMagicLinkEmail(email: string, url: string) {
|
||||
console.log(`\n[giris] Magic link for ${email}:\n${url}\n`);
|
||||
return;
|
||||
}
|
||||
const { Resend } = await import("resend");
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
const { subject, html, text } = magicLinkEpostasi(url);
|
||||
const { error } = await resend.emails.send({
|
||||
from: process.env.EMAIL_FROM ?? "KolayTercih <onboarding@resend.dev>",
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
// Düz metin alternatifi spam puanını düşürür ve HTML'i engelleyen
|
||||
// istemcilerde bağlantının okunabilir kalmasını sağlar.
|
||||
text,
|
||||
});
|
||||
// Resend SDK hata fırlatmaz, { data, error } döndürür. Kontrol etmezsek
|
||||
// doğrulanmamış domain/kota hatasında kullanıcı "gönderildi" ekranını
|
||||
// görür ama mail hiç çıkmaz — fırlatarak better-auth'a hata döndürüyoruz.
|
||||
if (error) {
|
||||
console.error(`[giris] Magic link gönderilemedi (${email}):`, error);
|
||||
throw new Error(`Resend: ${error.name} — ${error.message}`);
|
||||
}
|
||||
// Gönderim hatası better-auth'a fırlatılır — aksi halde kullanıcı
|
||||
// "gönderildi" ekranını görür ama mail hiç çıkmaz.
|
||||
await epostaGonder(email, magicLinkEpostasi(url));
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, gte, sql } from "drizzle-orm";
|
||||
import { and, eq, gte, isNull, sql } from "drizzle-orm";
|
||||
import { appDb, schema } from "./appdb";
|
||||
|
||||
const { user, creditLedger, chatMessages } = schema;
|
||||
@@ -139,6 +139,8 @@ export async function grantCredits(opts: {
|
||||
.set({
|
||||
creditBalance: sql`${user.creditBalance} + ${opts.delta}`,
|
||||
...(opts.setHasPaket ? { hasPaket: true } : {}),
|
||||
// Kredi gelince bekleyen kredi-bitti hatırlatması iptal olur
|
||||
krediBittiAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(user.id, opts.userId));
|
||||
@@ -150,3 +152,26 @@ export async function grantCredits(opts: {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kredi-bitti anını işaretler (48 saatlik hatırlatma e-postasının saati).
|
||||
* Yalnızca ilk bitişte yazar (kolon doluysa no-op) ve hatırlatma zaten
|
||||
* gönderildiyse yeniden kurmaz — e-posta ömür boyu 1 kez. Hata yolunda
|
||||
* çağrıldığı için asla throw etmez.
|
||||
*/
|
||||
export async function krediBittiIsaretle(userId: string): Promise<void> {
|
||||
try {
|
||||
await appDb
|
||||
.update(user)
|
||||
.set({ krediBittiAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(user.id, userId),
|
||||
isNull(user.krediBittiAt),
|
||||
isNull(user.krediHatirlatmaGonderildiAt),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`[kredi] kredi-bitti işaretlenemedi (${userId}):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,47 @@ export function searchByRank(
|
||||
};
|
||||
}
|
||||
|
||||
/** netler tablosunun bir yılı: yerleşen son kişinin ders bazlı netleri. */
|
||||
export type ProgramNetSatiri = {
|
||||
yil: number;
|
||||
puanTuru: string | null;
|
||||
tabanPuan: number | null;
|
||||
obp: number | null;
|
||||
tytTrkNet: number | null;
|
||||
tytSosNet: number | null;
|
||||
tytMatNet: number | null;
|
||||
tytFenNet: number | null;
|
||||
aytMatNet: number | null;
|
||||
aytFizNet: number | null;
|
||||
aytKimNet: number | null;
|
||||
aytBioNet: number | null;
|
||||
aytTdeNet: number | null;
|
||||
aytTrh1Net: number | null;
|
||||
aytCog1Net: number | null;
|
||||
aytTrh2Net: number | null;
|
||||
aytCog2Net: number | null;
|
||||
aytDinNet: number | null;
|
||||
aytFelNet: number | null;
|
||||
ydtYdilNet: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Programa yerleşen son kişinin yıl bazlı netleri, yeniden eskiye sıralı.
|
||||
* Veri scripts/detay.ts ile YÖK Atlas /api/netler/search'ten doldurulur.
|
||||
*/
|
||||
export function netlerGetir(programId: string): ProgramNetSatiri[] {
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT yil, puanTuru, tabanPuan, obp,
|
||||
tytTrkNet, tytSosNet, tytMatNet, tytFenNet,
|
||||
aytMatNet, aytFizNet, aytKimNet, aytBioNet,
|
||||
aytTdeNet, aytTrh1Net, aytCog1Net, aytTrh2Net, aytCog2Net,
|
||||
aytDinNet, aytFelNet, ydtYdilNet
|
||||
FROM netler WHERE program_id = ? ORDER BY yil DESC`
|
||||
)
|
||||
.all(programId) as ProgramNetSatiri[];
|
||||
}
|
||||
|
||||
export type SihirbazFacetleri = {
|
||||
kategoriler: { ad: string; adet: number }[];
|
||||
iller: { il: string; adet: number }[];
|
||||
|
||||
36
src/lib/eposta-gonder.ts
Normal file
36
src/lib/eposta-gonder.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { EpostaIcerik } from "./eposta";
|
||||
|
||||
/**
|
||||
* Ortak e-posta göndericisi (Resend). Şablonlar eposta.ts'te, gönderim
|
||||
* burada — magic link (auth.ts) ve kredi hatırlatması aynı yoldan çıkar.
|
||||
* RESEND_API_KEY yoksa dev fallback: içerik terminale yazılır.
|
||||
*/
|
||||
export async function epostaGonder(
|
||||
to: string,
|
||||
icerik: EpostaIcerik,
|
||||
): Promise<void> {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.log(
|
||||
`\n[eposta] (dev) ${to} adresine gönderilecekti — ${icerik.subject}\n${icerik.text}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { Resend } = await import("resend");
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
const { error } = await resend.emails.send({
|
||||
from: process.env.EMAIL_FROM ?? "KolayTercih <onboarding@resend.dev>",
|
||||
to,
|
||||
subject: icerik.subject,
|
||||
html: icerik.html,
|
||||
// Düz metin alternatifi spam puanını düşürür ve HTML'i engelleyen
|
||||
// istemcilerde içeriğin okunabilir kalmasını sağlar.
|
||||
text: icerik.text,
|
||||
});
|
||||
// Resend SDK hata fırlatmaz, { data, error } döndürür. Kontrol etmezsek
|
||||
// doğrulanmamış domain/kota hatası sessizce yutulur — fırlatarak çağıran
|
||||
// tarafın (better-auth, hatırlatma taraması) haberdar olmasını sağlıyoruz.
|
||||
if (error) {
|
||||
console.error(`[eposta] Gönderilemedi (${to}):`, error);
|
||||
throw new Error(`Resend: ${error.name} — ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -61,9 +61,12 @@ function baslik(): string {
|
||||
function kabuk({
|
||||
onizleme,
|
||||
govde,
|
||||
baslikEtiketi,
|
||||
}: {
|
||||
onizleme: string;
|
||||
govde: string;
|
||||
/** <title> içeriği — genelde konuyla aynı. */
|
||||
baslikEtiketi: string;
|
||||
}): string {
|
||||
return `<!doctype html>
|
||||
<html lang="tr">
|
||||
@@ -71,7 +74,7 @@ function kabuk({
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>Merhaba, giriş bağlantını gönderdik</title>
|
||||
<title>${kacir(baslikEtiketi)}</title>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,300;12..96,600&family=Cal+Sans&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
@@ -141,6 +144,7 @@ export function magicLinkEpostasi(url: string): EpostaIcerik {
|
||||
html: kabuk({
|
||||
onizleme: "Giriş bağlantın 5 dakika geçerli.",
|
||||
govde,
|
||||
baslikEtiketi: "Merhaba, giriş bağlantını gönderdik",
|
||||
}),
|
||||
text: [
|
||||
"Merhaba, giriş bağlantını gönderdik",
|
||||
@@ -153,3 +157,49 @@ export function magicLinkEpostasi(url: string): EpostaIcerik {
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Kredi bittikten 48 saat sonra giden tek seferlik hatırlatma. KVKK sınıfı
|
||||
* netleşene dek yalnızca işlemsel dil: hesaptaki mevcut listeyi hatırlatır,
|
||||
* ödeme ancak ikincil bir cümlede geçer (bkz. PRD İP-A / A2).
|
||||
*/
|
||||
export function krediHatirlatmaEpostasi(): EpostaIcerik {
|
||||
const listemUrl = `${SITE_URL}/listem`;
|
||||
const konu = "Listen seni bekliyor — açık satırların hazır";
|
||||
const govde = `
|
||||
<h1 style="margin:0 0 12px;font-family:${BASLIK_TIPI};font-size:22px;line-height:1.35;font-weight:700;color:${LACIVERT};">
|
||||
Listen seni bekliyor
|
||||
</h1>
|
||||
<p style="margin:0 0 24px;">
|
||||
Yapay zekânın senin sıralamana göre kurduğu tercih listesi hesabında
|
||||
duruyor; gerekçeleri ve risk notlarıyla
|
||||
<strong style="color:${LACIVERT};">açık satırların hazır</strong>.
|
||||
Kaldığın yerden devam edebilirsin.
|
||||
</p>
|
||||
${buton(listemUrl, "Listeni gör")}
|
||||
<p style="margin:24px 0 0;font-size:13px;">
|
||||
Listenin tamamını açmak ya da danışmana soru sormak istersen kredi
|
||||
yükleyebilirsin — listen ve sohbet geçmişin olduğu gibi durur.
|
||||
</p>`;
|
||||
|
||||
return {
|
||||
subject: konu,
|
||||
html: kabuk({
|
||||
onizleme: "Yapay zekânın kurduğu listen hesabında duruyor.",
|
||||
govde,
|
||||
baslikEtiketi: konu,
|
||||
}),
|
||||
text: [
|
||||
"Listen seni bekliyor",
|
||||
"",
|
||||
"Yapay zekânın senin sıralamana göre kurduğu tercih listesi hesabında",
|
||||
"duruyor; gerekçeleri ve risk notlarıyla açık satırların hazır.",
|
||||
"",
|
||||
"Listeni görmek için:",
|
||||
listemUrl,
|
||||
"",
|
||||
"Listenin tamamını açmak ya da danışmana soru sormak istersen kredi",
|
||||
"yükleyebilirsin — listen ve sohbet geçmişin olduğu gibi durur.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
85
src/lib/harita-pinler.ts
Normal file
85
src/lib/harita-pinler.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Rapordan Türkiye haritası pinleri türetir — /sonuc ve /listem ortak kullanır.
|
||||
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { HaritaPin } from "@/components/tercih-haritasi";
|
||||
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
|
||||
import { dilimdenRisk, riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
||||
|
||||
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
|
||||
guvenli: 0,
|
||||
"az-riskli": 1,
|
||||
riskli: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Rapordan harita pinleri türetir. Açık satırlar üniversite bazında gruplanır
|
||||
* (kampüs konumunda, adıyla); kilitli satırlar il başına tek adsız "?" pinine
|
||||
* toplanır — maskeli veride üniversite adı zaten client'a inmediği için
|
||||
* harita paywall'ı delemez. Haritada karşılığı olmayan tercihler sayılır.
|
||||
*/
|
||||
export function pinleriTuret(
|
||||
rapor: RaporSonuc,
|
||||
adaySira: number,
|
||||
kilitliBaslangic: number,
|
||||
): { pinler: HaritaPin[]; haritaDisi: number } {
|
||||
const gruplar = new Map<string, HaritaPin>();
|
||||
let haritaDisi = 0;
|
||||
|
||||
rapor.tercihler.forEach((t, i) => {
|
||||
const p = rapor.programlar[t.programId];
|
||||
if (!p) return;
|
||||
const risk = riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim);
|
||||
const kilitli = i >= kilitliBaslangic;
|
||||
|
||||
if (kilitli) {
|
||||
const konum = ilKonum(p.il);
|
||||
if (!konum) {
|
||||
haritaDisi += 1;
|
||||
return;
|
||||
}
|
||||
const key = `kilit|${p.il}`;
|
||||
const mevcut = gruplar.get(key);
|
||||
if (mevcut) {
|
||||
mevcut.tercihSiralari.push(t.sira);
|
||||
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
|
||||
} else {
|
||||
// Açık pinlerle üst üste binmesin diye il merkezinin hafif altına
|
||||
gruplar.set(key, {
|
||||
id: key,
|
||||
universite: "",
|
||||
il: p.il,
|
||||
x: konum.x,
|
||||
y: konum.y + 9,
|
||||
tercihSiralari: [t.sira],
|
||||
risk,
|
||||
kilitli: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const konum = uniKonum(p.universite, p.il);
|
||||
if (!konum) {
|
||||
haritaDisi += 1;
|
||||
return;
|
||||
}
|
||||
const key = `${uniAdiNormalize(p.universite)}|${p.il ?? ""}`;
|
||||
const mevcut = gruplar.get(key);
|
||||
if (mevcut) {
|
||||
mevcut.tercihSiralari.push(t.sira);
|
||||
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
|
||||
} else {
|
||||
gruplar.set(key, {
|
||||
id: key,
|
||||
universite: p.universite,
|
||||
il: p.il,
|
||||
x: konum.x,
|
||||
y: konum.y,
|
||||
tercihSiralari: [t.sira],
|
||||
risk,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { pinler: [...gruplar.values()], haritaDisi };
|
||||
}
|
||||
87
src/lib/kredi-hatirlatma.ts
Normal file
87
src/lib/kredi-hatirlatma.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { and, eq, isNull, lt, lte } from "drizzle-orm";
|
||||
import { appDb, schema } from "./appdb";
|
||||
import { RAPOR_KREDI } from "./credits";
|
||||
import { krediHatirlatmaEpostasi } from "./eposta";
|
||||
import { epostaGonder } from "./eposta-gonder";
|
||||
|
||||
const { user } = schema;
|
||||
|
||||
// 48 saat sonra tek seferlik hatırlatma (PRD İP-A / A2). Env override'ları
|
||||
// lokal testte akışı dakikalara sıkıştırmak için var.
|
||||
const GECIKME_MS = Number(
|
||||
process.env.KREDI_HATIRLATMA_GECIKME_MS ?? 48 * 60 * 60 * 1000,
|
||||
);
|
||||
const ARALIK_MS = Number(
|
||||
process.env.KREDI_HATIRLATMA_ARALIK_MS ?? 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
/**
|
||||
* Kredisi bitmiş ve 48 saattir dönmemiş kullanıcılara hatırlatma e-postası
|
||||
* gönderir. Kredi yüklemek `krediBittiAt`'ı sıfırladığı (grantCredits) ve
|
||||
* bakiye filtresi de burada olduğu için yalnızca hâlâ takılı olanlar seçilir.
|
||||
* Gönderildi işareti e-postadan ÖNCE koşullu UPDATE ile atılır: yarışta
|
||||
* yalnızca bir taraf `rowsAffected === 1` görür — çift gönderim imkânsız,
|
||||
* Resend hatasında o kullanıcının hatırlatması yanar (bilinçli at-most-once).
|
||||
*/
|
||||
export async function krediHatirlatmaTaramasi(): Promise<void> {
|
||||
const esik = new Date(Date.now() - GECIKME_MS);
|
||||
const adaylar = await appDb
|
||||
.select({ id: user.id, email: user.email })
|
||||
.from(user)
|
||||
.where(
|
||||
and(
|
||||
lte(user.krediBittiAt, esik),
|
||||
isNull(user.krediHatirlatmaGonderildiAt),
|
||||
lt(user.creditBalance, RAPOR_KREDI),
|
||||
),
|
||||
)
|
||||
.limit(100);
|
||||
|
||||
for (const aday of adaylar) {
|
||||
const res = await appDb
|
||||
.update(user)
|
||||
.set({ krediHatirlatmaGonderildiAt: new Date() })
|
||||
.where(
|
||||
and(eq(user.id, aday.id), isNull(user.krediHatirlatmaGonderildiAt)),
|
||||
);
|
||||
if (res.rowsAffected !== 1) continue; // başka instance kazandı
|
||||
|
||||
try {
|
||||
await epostaGonder(aday.email, krediHatirlatmaEpostasi());
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[hatirlatma] Kredi hatırlatması gönderilemedi (${aday.email}):`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saatlik tarama zamanlayıcısı — instrumentation.ts'ten, sunucu başına bir
|
||||
* kez çağrılır. globalThis guard'ı dev hot-reload'da çifte interval'i önler
|
||||
* (appdb/index.ts deseni).
|
||||
*/
|
||||
export function krediHatirlatmaBaslat(): void {
|
||||
const g = globalThis as unknown as { __krediHatirlatmaKuruldu?: boolean };
|
||||
if (g.__krediHatirlatmaKuruldu) return;
|
||||
g.__krediHatirlatmaKuruldu = true;
|
||||
|
||||
let calisiyor = false;
|
||||
const tik = async () => {
|
||||
if (calisiyor) return; // önceki tarama uzadıysa üst üste binme
|
||||
calisiyor = true;
|
||||
try {
|
||||
await krediHatirlatmaTaramasi();
|
||||
} catch (err) {
|
||||
console.error("[hatirlatma] Tarama hatası:", err);
|
||||
} finally {
|
||||
calisiyor = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Container yeniden başlarsa saatlik açıklık büyümesin diye kısa bir
|
||||
// gecikmeyle açılış taraması da yapılır.
|
||||
setTimeout(tik, 60_000);
|
||||
setInterval(tik, ARALIK_MS);
|
||||
}
|
||||
@@ -7,6 +7,12 @@ import type { RaporSonuc } from "./ai/rapor";
|
||||
|
||||
export const ACIK_SATIR = 3;
|
||||
|
||||
/**
|
||||
* Maske çıktısı: kilitli uyarıların yalnızca SAYISI iner, metinleri inmez.
|
||||
* RaporSchema'ya (LLM çıktısı) eklenmez — bu alan yalnızca maskede doğar.
|
||||
*/
|
||||
export type MaskeliRapor = RaporSonuc & { kilitliUyariSayisi?: number };
|
||||
|
||||
const SAHTE_GEREKCE =
|
||||
"Bu tercih için kişisel gerekçe paketle açılır.";
|
||||
const SAHTE_RISK =
|
||||
@@ -21,7 +27,7 @@ function degerlendirmeKirp(metin: string): string {
|
||||
return `${cumleler.slice(0, 2).join(" ").trim()} …`;
|
||||
}
|
||||
|
||||
export function raporMaskele(rapor: RaporSonuc): RaporSonuc {
|
||||
export function raporMaskele(rapor: RaporSonuc): MaskeliRapor {
|
||||
const tercihler = rapor.tercihler.map((t, i) =>
|
||||
i < ACIK_SATIR
|
||||
? t
|
||||
@@ -36,8 +42,10 @@ export function raporMaskele(rapor: RaporSonuc): RaporSonuc {
|
||||
return {
|
||||
tercihler,
|
||||
genelDegerlendirme: degerlendirmeKirp(rapor.genelDegerlendirme),
|
||||
// Kişisel uyarılar ücretli katmandır; istemciye hiç inmez.
|
||||
uyarilar: [],
|
||||
// Teaser: ilk uyarı açık kalır, kalan uyarıların yalnızca sayısı iner —
|
||||
// kilitli metinler istemciye hiç ulaşmaz.
|
||||
uyarilar: rapor.uyarilar?.slice(0, 1) ?? [],
|
||||
kilitliUyariSayisi: Math.max(0, (rapor.uyarilar?.length ?? 0) - 1),
|
||||
// Program adı, üniversite ve 2021–2025 taban sıralamaları ham veridir.
|
||||
programlar: rapor.programlar,
|
||||
};
|
||||
|
||||
@@ -33,6 +33,25 @@ export function bolumBazAdi(isim: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* /universite/[slug] sayfa slug'ı: harita.ts'teki uniAdiNormalize ile aynı
|
||||
* kural (sondaki "(İL)" eki atılır). Katalog haritası DB'deki tüm universite
|
||||
* değerlerinden kurulduğu için bu slug her zaman var olan bir sayfaya çıkar.
|
||||
*/
|
||||
export function uniSayfaSlug(ad: string): string {
|
||||
return turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* /bolum/[slug] sayfa slug'ı: katalog.ts'teki bolumSlugFromIsim ile aynı kural
|
||||
* (bolumBazAdi + turkishSlugify). Bölüm haritası DB'deki tüm isim
|
||||
* değerlerinden kurulduğu için bu slug da her zaman var olan bir sayfaya çıkar
|
||||
* (bkz. liste-cekmecesi'ndeki bolumHref).
|
||||
*/
|
||||
export function bolumSayfaSlug(isim: string): string {
|
||||
return turkishSlugify(bolumBazAdi(isim));
|
||||
}
|
||||
|
||||
/** ALL-CAPS veritabanı adlarını tr-TR başlık düzenine çevirir (İSTANBUL → İstanbul). */
|
||||
export function trBaslikDuzeni(s: string): string {
|
||||
return s
|
||||
|
||||
83
src/lib/tadimlik-havuzu.ts
Normal file
83
src/lib/tadimlik-havuzu.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// Girişsiz yapay zekâ tadımlığının sunucu katmanı: batch üretilmiş
|
||||
// (scripts/tadimlik-uret.ts) satırlar app.db'deki tadimlik_havuzu'ndan
|
||||
// SEÇİLİR — anonim istek LLM'e asla gitmez, seçim her zaman sunucuda.
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { appDb, schema } from "./appdb";
|
||||
import { turkishSlugify } from "./slug";
|
||||
import type { PuanTuruKey } from "./db";
|
||||
import type { Dilim } from "./rapor-havuzu";
|
||||
import type { RaporSonuc } from "./ai/rapor";
|
||||
|
||||
/** Sihirbaz kategorisi seçilmemiş kullanıcı için kova×tür başına fallback satır. */
|
||||
export const GENEL_KATEGORI = "genel";
|
||||
|
||||
/**
|
||||
* Havuz anahtarındaki sıra kovaları. Eşikler analitik.ts'teki siraKovasi()
|
||||
* ile birebir aynı tutulmalı (oradaki Türkçe etiketler DB anahtarı olamaz).
|
||||
* temsiliSira: batch üretimde "bu kovadaki tipik aday" olarak kullanılan sıra.
|
||||
*/
|
||||
export const KOVALAR = [
|
||||
{ slug: "0-10k", temsiliSira: 5_000, max: 10_000 },
|
||||
{ slug: "10-50k", temsiliSira: 30_000, max: 50_000 },
|
||||
{ slug: "50-100k", temsiliSira: 75_000, max: 100_000 },
|
||||
{ slug: "100-250k", temsiliSira: 175_000, max: 250_000 },
|
||||
{ slug: "250-500k", temsiliSira: 375_000, max: 500_000 },
|
||||
{ slug: "500k-plus", temsiliSira: 750_000, max: Number.POSITIVE_INFINITY },
|
||||
] as const;
|
||||
|
||||
export type Kova = (typeof KOVALAR)[number];
|
||||
|
||||
export function kovaBul(sira: number): Kova {
|
||||
return KOVALAR.find((k) => sira <= k.max) ?? KOVALAR[KOVALAR.length - 1];
|
||||
}
|
||||
|
||||
/** "Mühendislik & Teknoloji" → "muhendislik-teknoloji" (havuz anahtarı). */
|
||||
export function kategoriSlugBul(kategori: string): string {
|
||||
return turkishSlugify(kategori);
|
||||
}
|
||||
|
||||
export interface TadimlikSatir {
|
||||
programId: string;
|
||||
dilim: Dilim;
|
||||
gerekce: string;
|
||||
riskNotu: string;
|
||||
trendOzeti: string;
|
||||
program: RaporSonuc["programlar"][string];
|
||||
}
|
||||
|
||||
/**
|
||||
* Kova × tür × kategori anahtarıyla tadımlık satırı seçer. Kategoriye özel
|
||||
* satır yoksa "genel"e düşer; o da yoksa null — çağıran taraf bileşeni hiç
|
||||
* render etmez (boş/generic tadımlık gösterilmez, PRD kuralı).
|
||||
*/
|
||||
export async function tadimlikSec(
|
||||
sira: number,
|
||||
tur: PuanTuruKey,
|
||||
kategoriSlug?: string,
|
||||
): Promise<TadimlikSatir | null> {
|
||||
const kova = kovaBul(sira);
|
||||
const sluglar = [
|
||||
...new Set([kategoriSlug ?? GENEL_KATEGORI, GENEL_KATEGORI]),
|
||||
];
|
||||
for (const slug of sluglar) {
|
||||
const satir = await appDb.query.tadimlikHavuzu.findFirst({
|
||||
where: and(
|
||||
eq(schema.tadimlikHavuzu.kovaSlug, kova.slug),
|
||||
eq(schema.tadimlikHavuzu.tur, tur),
|
||||
eq(schema.tadimlikHavuzu.kategoriSlug, slug),
|
||||
),
|
||||
});
|
||||
if (satir) {
|
||||
return {
|
||||
programId: satir.programId,
|
||||
dilim: satir.dilim,
|
||||
gerekce: satir.gerekce,
|
||||
riskNotu: satir.riskNotu,
|
||||
trendOzeti: satir.trendOzeti,
|
||||
program: satir.program as RaporSonuc["programlar"][string],
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user