/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>
232 lines
8.5 KiB
TypeScript
232 lines
8.5 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { ArrowRight } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
SihirbazAdimlarLazy,
|
||
preloadSihirbazAdimlar,
|
||
} from "./sihirbaz-adimlar-lazy";
|
||
import { authClient } from "@/lib/auth-client";
|
||
import { olay, siraKovasi } from "@/lib/analitik";
|
||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||
import {
|
||
profilDuzenlemeyiAc,
|
||
useTercihProfili,
|
||
} from "@/features/liste/hooks/use-tercih-profili";
|
||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||
import { PUAN_TURU_ETIKET, 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;
|
||
|
||
export function HeroForm() {
|
||
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 [modalAcik, setModalAcik] = useState(false);
|
||
const [hata, setHata] = useState<string | null>(null);
|
||
const [girisli, setGirisli] = useState(false);
|
||
// Daha önce sıralama girmiş ziyaretçi formu yeniden doldurmasın:
|
||
// kayıtlı profil varsa doğrudan /sonuc CTA'sı gösterilir.
|
||
const kayitliProfil = useTercihProfili();
|
||
const router = useRouter();
|
||
|
||
// 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 handleSubmit(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 mount'ta değil burada, facet isteğiyle PARALEL çözülür:
|
||
// landing ziyareti başına gereksiz auth turu atılmaz (sayfa statik kalır).
|
||
// Facetler dedup+TTL cache'li: aynı sıra/tür tekrar girilirse ağa çıkmaz.
|
||
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);
|
||
setModalAcik(true);
|
||
olay("sira_girildi", {
|
||
kaynak: "hero",
|
||
tur,
|
||
sira_kovasi: siraKovasi(value),
|
||
});
|
||
} catch {
|
||
olay("sira_form_hata", { kaynak: "hero" });
|
||
setHata("Bir şeyler ters gitti, tekrar dener misin?");
|
||
} finally {
|
||
setYukleniyor(false);
|
||
}
|
||
}
|
||
|
||
// Seçimler tamam: sakla ve herkes doğrudan /sonuc'a gitsin. Girişli
|
||
// kullanıcıda orada otomatik üretim başlar; girişsiz kullanıcı önce
|
||
// ücretsiz tabloyu görür, giriş CTA'sı ödülün kaydedilmesi olarak
|
||
// oradan gelir (değer önce — giriş duvarı sihirbazın hemen arkasında değil).
|
||
function secimleriTamamla(secimler: SihirbazSecimleri) {
|
||
if (!sira) return;
|
||
const profil = { sira, tur, secimler };
|
||
tercihProfiliYaz(profil);
|
||
setModalAcik(false);
|
||
router.push(sonucHref(girisli ? { sihirbaz: "1", kaynak: "hero" } : { hazir: "1" }));
|
||
}
|
||
|
||
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.
|
||
// "Farklı sıralama gir" navbar'daki Değiştir ile aynı düzenleme modalını
|
||
// (TercihProfiliKapisi, layout'ta monte) açar.
|
||
if (kayitliProfil) {
|
||
return (
|
||
<div className="flex w-full max-w-md flex-col items-center gap-3">
|
||
<Button
|
||
asChild
|
||
size="lg"
|
||
className="w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
|
||
>
|
||
<Link href={sonucHref()}>
|
||
Üniversitelere göz at
|
||
<ArrowRight className="size-4" aria-hidden />
|
||
</Link>
|
||
</Button>
|
||
<p className="text-sm text-slate-600">
|
||
{kayitliProfil.sira.toLocaleString("tr-TR")} ·{" "}
|
||
{PUAN_TURU_ETIKET[kayitliProfil.tur] ?? kayitliProfil.tur} sıralamanla
|
||
devam edersin.{" "}
|
||
<button
|
||
type="button"
|
||
onClick={profilDuzenlemeyiAc}
|
||
className="cursor-pointer font-medium text-slate-900 underline underline-offset-2 hover:text-slate-700"
|
||
>
|
||
Farklı sıralama gir
|
||
</button>
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
// 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ü"
|
||
className="flex flex-wrap justify-center gap-1.5"
|
||
>
|
||
{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>
|
||
|
||
<form
|
||
onSubmit={handleSubmit}
|
||
className="flex w-full flex-col gap-3 sm:flex-row"
|
||
>
|
||
<label htmlFor="siralama" className="sr-only">
|
||
YKS başarı sıralaması
|
||
</label>
|
||
<Input
|
||
id="siralama"
|
||
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 ? "siralama-hata" : undefined}
|
||
// flex-1 yalnızca yatay dizilimde (sm+): mobilde form flex-col olduğu
|
||
// için flex-1 inputun yüksekliğini eziyordu. Mobilde geniş padding
|
||
// + büyük yazı; sm ve üzeri eski görünüm.
|
||
className="h-14 bg-background px-6 py-3 text-lg sm:h-12 sm:flex-1 sm:px-2.5 sm:py-1 sm:text-base"
|
||
/>
|
||
<Button
|
||
type="submit"
|
||
size="lg"
|
||
disabled={yukleniyor}
|
||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||
>
|
||
Yapay Zeka listemi kur
|
||
<ArrowRight className="size-4" aria-hidden />
|
||
</Button>
|
||
</form>
|
||
|
||
{hata ? (
|
||
<p
|
||
id="siralama-hata"
|
||
role="alert"
|
||
className="self-start text-sm font-medium text-red-600"
|
||
>
|
||
{hata}
|
||
</p>
|
||
) : null}
|
||
|
||
<Dialog open={modalAcik} onOpenChange={setModalAcik}>
|
||
<DialogContent className="max-h-[92dvh] gap-0 overflow-y-auto p-6 text-left sm:max-w-3xl">
|
||
<DialogTitle className="sr-only">Tercih listesi sihirbazı</DialogTitle>
|
||
<DialogDescription className="sr-only">
|
||
Sıralamana uygun seçimlerini yap, tercih planına geç.
|
||
</DialogDescription>
|
||
{facetler && sira ? (
|
||
<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}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|