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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user