/sonuc'taki otomatik üretim kaynağı sabit "giris_donusu" yazıyordu; oysa ?sihirbaz=1 üreten hiçbir yer giriş dönüşü değil — gerçek giriş dönüşü /listem'e doğrudan iniyor, /sonuc'a uğramıyor. Hero, CTA formu, arama kartı, + kapısı, boş liste ekranı ve rapor-yok CTA'sı tek yanlış kovada toplanıyordu, yani P2'nin ölçmek istediği soru ölçülmüyordu. Etiket artık ?sihirbaz=1'i üreten tarafta kuruluyor ve /sonuc üzerinden geçiriliyor; kapıdan gelenler tetikten türetiliyor. giris_donusu yalnız gerçek callback yolunda kalıyor. Güvenlik denetimi G3 · docs/ekip-raporlari/2026-09-22/07-guvenlik-denetimi.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
440 lines
17 KiB
TypeScript
440 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import { useId, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
ArrowRight,
|
||
ChartNoAxesCombined,
|
||
Database,
|
||
ListChecks,
|
||
Trophy,
|
||
X,
|
||
} from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Drawer,
|
||
DrawerClose,
|
||
DrawerContent,
|
||
DrawerDescription,
|
||
DrawerTitle,
|
||
} from "@/components/ui/drawer";
|
||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||
import {
|
||
SihirbazAdimlarLazy,
|
||
preloadSihirbazAdimlar,
|
||
} from "./sihirbaz-adimlar-lazy";
|
||
import { authClient } from "@/lib/auth-client";
|
||
import { olay, siraKovasi } from "@/lib/analitik";
|
||
import { cn } from "@/lib/utils";
|
||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||
import { sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||
|
||
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
|
||
const PUAN_TURLERI_SECENEK = [
|
||
{ deger: "say", etiket: "Sayısal" },
|
||
{ deger: "ea", etiket: "Eşit Ağırlık" },
|
||
{ deger: "soz", etiket: "Sözel" },
|
||
{ deger: "dil", etiket: "Dil" },
|
||
{ deger: "tyt", etiket: "TYT" },
|
||
] as const;
|
||
|
||
// Kanıt üçlüsü arama funnel kartıyla aynı dili konuşur (bkz. arama-funnel-karti)
|
||
const KANITLAR = [
|
||
{ ikon: ListChecks, etiket: "24 tercihlik plan" },
|
||
{ ikon: ChartNoAxesCombined, etiket: "Risk analizi" },
|
||
{ ikon: Database, etiket: "YÖK Atlas verisi" },
|
||
] as const;
|
||
|
||
/**
|
||
* Programatik SEO sayfalarındaki funnel girişi. Statik/SSR çıktı her zaman
|
||
* profilsiz hâldir (sayfalar prerender edilmeye devam eder); hydration
|
||
* sonrası kayıtlı tercih profili bulunursa "sıralamanı gir" yerine doğrudan
|
||
* sonuç sayfasına götüren CTA gösterilir — sıralamasını girmiş adaya bir
|
||
* daha girmesini söylemeyiz.
|
||
*
|
||
* 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. docs/tasarim/cta-cekmece-tasarimi.md.
|
||
*
|
||
* `baslik` iki kartın (profilli/profilsiz) başlığını, `aciklama` yalnız
|
||
* profilsiz karttaki paragrafı değiştirir — katalog sayfaları kartı üstündeki
|
||
* tabloya bağlayan köprü cümlesini buradan verir. Başlık ve açıklamalar
|
||
* yerleşme vaadi içermez: ürün sıralamaya yakın programları listeler.
|
||
*
|
||
* `varyant="kompakt"`: katalog sayfalarında başlığın hemen altına, tablonun
|
||
* ÜSTÜNE konan tek satırlık sürüm. Sıralama alanı çekmecede değil doğrudan
|
||
* sayfadadır (tek sayfada çıkan ziyaretçi alttaki karta hiç inmiyor); gönderince
|
||
* aynı çekmece sihirbaz adımıyla açılır. Aynı sayfada alttaki kartla birlikte
|
||
* durduğu için alan kimlikleri `useId` ile üretilir, analitik olayları da
|
||
* `konum` ("ust" | "alt") özelliğiyle ayrışır — olay adları değişmez.
|
||
*/
|
||
export function CtaSiraForm({
|
||
baslik,
|
||
aciklama,
|
||
varyant = "kart",
|
||
konum = "alt",
|
||
}: {
|
||
baslik?: string;
|
||
aciklama?: string;
|
||
varyant?: "kart" | "kompakt";
|
||
/** Yalnız ölçüm içindir: formun sayfadaki yeri (tablonun üstü / altı). */
|
||
konum?: "ust" | "alt";
|
||
}) {
|
||
const profil = useTercihProfili();
|
||
const router = useRouter();
|
||
const kompakt = varyant === "kompakt";
|
||
// Aynı sayfada iki form (üst satır + alt kart) olabildiği için sabit id yok.
|
||
const kimlik = useId();
|
||
const alanId = `${kimlik}-siralama`;
|
||
const hataId = `${kimlik}-siralama-hata`;
|
||
const baslikId = `${kimlik}-baslik`;
|
||
|
||
const [acik, setAcik] = useState(false);
|
||
const [adim, setAdim] = useState<"sira" | "sihirbaz">("sira");
|
||
const [siralama, setSiralama] = useState("");
|
||
const [tur, setTur] = useState<string>("say");
|
||
const [sira, setSira] = useState<number | null>(null);
|
||
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
|
||
const [yukleniyor, setYukleniyor] = useState(false);
|
||
const [hata, setHata] = useState<string | null>(null);
|
||
const [girisli, setGirisli] = useState(false);
|
||
// Huni girişi ölçümü: ref yalnızca profilsiz daldaki karta bağlanır; kayıtlı
|
||
// profilli ziyaretçi formu hiç görmediğinden sayılmaz. Sayfa ayrımı için
|
||
// özellik gerekmez — Rybbit olayın pathname'ini zaten yazar. `konum` hem
|
||
// özellik hem tekilleştirme ekidir: üstteki satır olayı atınca alttaki kartın
|
||
// gösterimi yutulmasın (oturum başına konum başına en fazla 1).
|
||
const formRef = useGorunumOlayi<HTMLElement>(
|
||
"cta_form_goruntulendi",
|
||
{ konum },
|
||
konum,
|
||
);
|
||
|
||
// Yazarken binlik ayracıyla biçimle (85000 → 85.000)
|
||
function siralamaDegisti(ham: string) {
|
||
setHata(null);
|
||
const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
|
||
setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "");
|
||
}
|
||
|
||
async function siraGonder(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
const value = Number(siralama.replace(/\./g, ""));
|
||
if (!value || value < 1 || value > 4_000_000) {
|
||
setHata("Geçerli bir başarı sıralaması gir (ör. 85.000).");
|
||
return;
|
||
}
|
||
setYukleniyor(true);
|
||
try {
|
||
// Oturum bilgisi facet isteğiyle PARALEL çözülür; sayfa statik kalır
|
||
// (hero formundaki desenle aynı — bkz. hero-form.tsx).
|
||
const [veri, oturum] = await Promise.all([
|
||
onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>(
|
||
`/api/facetler?sira=${value}&tur=${tur}`,
|
||
),
|
||
authClient.getSession().catch(() => null),
|
||
]);
|
||
setGirisli(Boolean(oturum?.data?.user));
|
||
setSira(value);
|
||
setFacetler(veri.facetler);
|
||
setAdim("sihirbaz");
|
||
// Kompakt satırda sıralama sayfada girilir; çekmece ancak şimdi,
|
||
// doğrudan sihirbaz adımıyla açılır (kartta zaten açıktır).
|
||
setAcik(true);
|
||
olay("sira_girildi", {
|
||
kaynak: "cta",
|
||
konum,
|
||
tur,
|
||
sira_kovasi: siraKovasi(value),
|
||
});
|
||
} catch {
|
||
olay("sira_form_hata", { kaynak: "cta", konum });
|
||
setHata("Bir şeyler ters gitti, tekrar dener misin?");
|
||
} finally {
|
||
setYukleniyor(false);
|
||
}
|
||
}
|
||
|
||
// Seçimler tamam: sakla, çekmeceyi kapat ve /sonuc'a git. Girişli
|
||
// kullanıcıda orada otomatik üretim başlar; girişsiz kullanıcı önce
|
||
// ücretsiz tabloyu görür (değer önce — giriş duvarı sihirbazın hemen
|
||
// arkasında değil).
|
||
function secimleriTamamla(secimler: SihirbazSecimleri) {
|
||
if (sira == null) return;
|
||
const yeniProfil = { sira, tur, secimler };
|
||
tercihProfiliYaz(yeniProfil);
|
||
setAcik(false);
|
||
router.push(sonucHref(girisli ? { sihirbaz: "1", kaynak: "cta" } : { hazir: "1" }));
|
||
}
|
||
|
||
function acikDegisti(v: boolean) {
|
||
setAcik(v);
|
||
if (!v) {
|
||
setHata(null);
|
||
// Kompakt satırda çekmecenin sıra adımı yoktur; her gönderim adımı ve
|
||
// facet'leri zaten baştan yazar (kapanış animasyonunda içerik boşalmasın).
|
||
if (kompakt) return;
|
||
// Yeniden açılışta sıfırdan başla; yazılan sıralama kaybolmasın.
|
||
setAdim("sira");
|
||
setFacetler(null);
|
||
}
|
||
}
|
||
|
||
// Sıra girişi: kartta çekmecenin ilk adımı, kompakt satırda sayfanın kendisi.
|
||
// Kompakt sürüm masaüstünde (lg) tek satırdır: çipler · alan · buton.
|
||
const siraFormu = (
|
||
<form
|
||
onSubmit={siraGonder}
|
||
className={
|
||
kompakt
|
||
? "flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-center"
|
||
: "flex flex-col gap-3"
|
||
}
|
||
>
|
||
<div
|
||
role="radiogroup"
|
||
aria-label="Puan türü"
|
||
className={cn("flex flex-wrap gap-1.5", kompakt && "lg:shrink-0")}
|
||
>
|
||
{PUAN_TURLERI_SECENEK.map((t) => (
|
||
<button
|
||
key={t.deger}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={tur === t.deger}
|
||
onClick={() => setTur(t.deger)}
|
||
className={
|
||
tur === t.deger
|
||
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200"
|
||
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
|
||
}
|
||
>
|
||
{t.etiket}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<label htmlFor={alanId} className="sr-only">
|
||
YKS başarı sıralaması
|
||
</label>
|
||
<div
|
||
className={cn(
|
||
"flex flex-col gap-3 sm:flex-row",
|
||
kompakt && "lg:min-w-0 lg:flex-1",
|
||
)}
|
||
>
|
||
<div className="relative sm:flex-1">
|
||
<Trophy
|
||
className="pointer-events-none absolute left-4 top-1/2 size-4 -translate-y-1/2 text-slate-400"
|
||
aria-hidden
|
||
/>
|
||
<Input
|
||
id={alanId}
|
||
inputMode="numeric"
|
||
autoComplete="off"
|
||
placeholder="YKS başarı sıralaman (ör. 85.000)"
|
||
value={siralama}
|
||
onChange={(e) => siralamaDegisti(e.target.value)}
|
||
onFocus={preloadSihirbazAdimlar}
|
||
aria-invalid={hata ? true : undefined}
|
||
aria-describedby={hata ? hataId : undefined}
|
||
className="h-12 bg-background pl-11 text-base"
|
||
/>
|
||
</div>
|
||
<Button
|
||
type="submit"
|
||
disabled={yukleniyor}
|
||
className="h-12 cursor-pointer bg-orange-500 px-6 text-white transition-colors duration-200 hover:bg-orange-600"
|
||
>
|
||
{yukleniyor ? "Hazırlanıyor…" : "Programları gör"}
|
||
<ArrowRight className="size-4" aria-hidden />
|
||
</Button>
|
||
</div>
|
||
{hata ? (
|
||
<p
|
||
id={hataId}
|
||
role="alert"
|
||
className={cn(
|
||
"text-sm font-medium text-red-600",
|
||
kompakt && "lg:basis-full",
|
||
)}
|
||
>
|
||
{hata}
|
||
</p>
|
||
) : null}
|
||
</form>
|
||
);
|
||
|
||
// Çekmece iki varyantta da aynıdır; kompakt satırda sıra adımı sayfada
|
||
// olduğundan çekmece yalnız sihirbazı taşır.
|
||
const cekmece = (
|
||
<Drawer open={acik} onOpenChange={acikDegisti} handleOnly>
|
||
<DrawerContent
|
||
className="max-h-[92dvh] bg-white sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
|
||
>
|
||
{/* Görünür başlıklar adıma göre değişir; erişilebilir ad sabit kalır. */}
|
||
<DrawerTitle className="sr-only">
|
||
Sıralamana yakın programlar hangileri?
|
||
</DrawerTitle>
|
||
<DrawerDescription className="sr-only">
|
||
YKS başarı sıralamanı gir; sıralamana uygun programları bu sayfadan
|
||
ayrılmadan gör.
|
||
</DrawerDescription>
|
||
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
|
||
<X className="size-4" aria-hidden />
|
||
<span className="sr-only">Kapat</span>
|
||
</DrawerClose>
|
||
|
||
<div
|
||
className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6"
|
||
>
|
||
{!kompakt && adim === "sira" ? (
|
||
<div className="flex flex-col gap-5">
|
||
<div>
|
||
<SectionEyebrow>Saniyede gör</SectionEyebrow>
|
||
<h2 className="mt-3 font-heading text-2xl font-bold text-slate-900">
|
||
Sıralamana yakın programlar hangileri?
|
||
</h2>
|
||
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-600">
|
||
Puan türünü seç, başarı sıralamanı yaz; hayal, dengeli ve
|
||
güvenli dilimlerindeki programları bu sayfadan ayrılmadan
|
||
gör. Ücretsiz, üyelik gerekmez.
|
||
</p>
|
||
</div>
|
||
|
||
{siraFormu}
|
||
|
||
<ul
|
||
aria-label="Neler alacaksın"
|
||
className="flex flex-wrap gap-x-4 gap-y-2 border-t border-slate-100 pt-4"
|
||
>
|
||
{KANITLAR.map(({ ikon: Ikon, etiket }) => (
|
||
<li
|
||
key={etiket}
|
||
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-600"
|
||
>
|
||
<Ikon className="size-3.5 text-primary" aria-hidden />
|
||
{etiket}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
) : facetler && sira != null ? (
|
||
<SihirbazAdimlarLazy
|
||
key={`${sira}-${tur}`}
|
||
sira={sira}
|
||
tur={tur}
|
||
facetler={facetler}
|
||
sonButonEtiketi={
|
||
girisli ? "Yapay Zeka listemi kur (3 kredi)" : "Ücretsiz tablonu gör"
|
||
}
|
||
onTamamla={secimleriTamamla}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</DrawerContent>
|
||
</Drawer>
|
||
);
|
||
|
||
if (kompakt) {
|
||
// CLS: kayıtlı profil hydration SONRASI bulunur. Satır tablonun üstünde
|
||
// olduğundan yüksekliği o anda değişmemeli — form yerinde (görünmez ve
|
||
// inert) kalır, profilli kısayol aynı ızgara hücresine bindirilir; hücrenin
|
||
// yüksekliğini her iki durumda da form belirler.
|
||
return (
|
||
<section
|
||
aria-labelledby={baslikId}
|
||
className="rounded-2xl border border-slate-200 bg-white p-4 sm:p-5"
|
||
>
|
||
<p className="text-sm leading-6 text-slate-600">
|
||
<strong
|
||
id={baslikId}
|
||
className="font-heading text-base font-bold text-slate-900"
|
||
>
|
||
{baslik ?? "Sıralaman bu tabanların neresinde?"}
|
||
</strong>{" "}
|
||
Ücretsiz, üyelik gerekmez.
|
||
</p>
|
||
<div className="mt-3 grid">
|
||
<div
|
||
ref={profil ? undefined : formRef}
|
||
inert={profil ? true : undefined}
|
||
className={cn("col-start-1 row-start-1", profil && "invisible")}
|
||
>
|
||
{siraFormu}
|
||
</div>
|
||
{profil ? (
|
||
<div className="col-start-1 row-start-1 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<p className="text-sm leading-6 text-slate-600">
|
||
Sıralaman kayıtlı ({profil.sira.toLocaleString("tr-TR")}. sıra).
|
||
</p>
|
||
<Link
|
||
href={sonucHref()}
|
||
className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-orange-500 px-6 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 />
|
||
</Link>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
{cekmece}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
if (profil) {
|
||
return (
|
||
<section className="rounded-2xl border border-primary/20 bg-primary/5 p-6 sm:p-8">
|
||
<h2 className="font-heading text-xl font-bold text-slate-900">
|
||
{baslik ?? "Sıralamana yakın programlar hangileri? Saniyede gör."}
|
||
</h2>
|
||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||
Sıralaman kayıtlı ({profil.sira.toLocaleString("tr-TR")}. sıra) —
|
||
gerçek YÖK Atlas verisiyle hayal, dengeli ve güvenli dilimlerindeki
|
||
programların hazır.
|
||
</p>
|
||
<Link
|
||
href={sonucHref()}
|
||
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 />
|
||
</Link>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<section
|
||
ref={formRef}
|
||
className="rounded-2xl border border-primary/20 bg-primary/5 p-6 sm:p-8"
|
||
>
|
||
<h2 className="font-heading text-xl font-bold text-slate-900">
|
||
{baslik ?? "Sıralamana yakın programlar hangileri? Saniyede gör."}
|
||
</h2>
|
||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||
{aciklama ??
|
||
"YKS başarı sıralamanı gir; gerçek YÖK Atlas verisiyle sıralamana uygun hayal, dengeli ve güvenli dilimlerindeki programları anında listele. Ücretsiz, üyelik gerekmez."}
|
||
</p>
|
||
<Button
|
||
type="button"
|
||
onClick={() => setAcik(true)}
|
||
onPointerEnter={preloadSihirbazAdimlar}
|
||
onFocus={preloadSihirbazAdimlar}
|
||
className="mt-4 h-11 cursor-pointer bg-orange-500 px-6 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
|
||
>
|
||
Sıralamanı gir, programları gör
|
||
<ArrowRight className="size-4" aria-hidden />
|
||
</Button>
|
||
|
||
{cekmece}
|
||
</section>
|
||
);
|
||
}
|