Files
kolaytercih/src/features/sihirbaz/components/cta-sira-form.tsx
bilalgursen 2099f20ed1 feat(tasarim): form alanları hap, CTA bandı tek kaynak, masonry piksel zemin, çekmeceler §4.10
- Input/Select/Textarea: shadcn'in ince varsayılanı yerine üst çubuk
  haplarıyla aynı kalıp (h-12, rounded-full, slate-200, 16 px; md:text-sm
  yok); kullanım yerleri yalnız yerleşim sınıfı taşır. DESIGN.md §4.6
  tam sınıf dizisi; denetime ince-form-alani (hata) ve ham-input (uyarı)
- CtaBandi (mavi zemin + piksel doku) tek kaynak: /sonuc iki panel, katalog
  altı kart, "Kendi Listem" çekmecesi altı. Ana sayfa kapanışı koyu
  PikselCtaYuzeyi + 24'lük piksel liste kreatifiyle kalır (Bilal: "orası
  bayağı iyiydi"); yazılıp silinen satır kaldırıldı
- PixelMasonry: CTA arka planları büyüklü küçüklü, hafif kaymış kareler;
  eşit ızgaralı PixelField kaldırıldı
- Çekmece başlığındaki 12×2 piksel liste kaldırıldı (çok dolu); sayı +
  ilerleme çubuğu + risk dağılımı geri geldi
- "Saniyede gör" çekmecesi §4.10 düzeninde: ortalı DrawerTitle + bağlam,
  rozet ve çizgili "neler alacaksın" listesi yok; kapı modalındaki iki
  rozet kalktı; denetime modal-rozet (hata)
- Kapı panelinde 3 px odak halkası kırpılmıyor (px-px → p-1)
- Liste satırı: üniversite adı + tür/il/süre adın altında tek akan satır,
  telefonda text-xs (SATIR_ALT_GRUP); katalog listelerinde ad kendi
  satırında (SATIR_GRUP space-y-2). Ölçüm 375 px canlı bölüm sayfası:
  satır yüksekliğini metin değil alt alta iki 44 px düğme belirliyor

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 12:08:24 +03:00

477 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useId, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight, 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 { PagePixelDivider } 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 { CtaBandi } from "@/components/cta-bandi";
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;
// Kayıtlı profilli CTA'lar /listem boş durumunun dilini taşır (bkz.
// listem-bos-cta.tsx): büyük turuncu buton, piksel ayraç. Sınıf seti tek yerde.
const PROFILLI_CTA_SINIF =
"h-12 cursor-pointer bg-orange-500 px-6 text-white transition-colors duration-200 hover:bg-orange-600";
function turEtiketi(tur: string) {
return PUAN_TURLERI_SECENEK.find((t) => t.deger === tur)?.etiket ?? tur.toUpperCase();
}
/** "Sayısal · 100.000. sıra" — kayıtlı profilin kısa özeti. */
function KayitliSira({
sira,
tur,
beyaz = false,
}: {
sira: number;
tur: string;
/** Mavi CTA bandının üstünde (CtaBandi): beyaz yazı */
beyaz?: boolean;
}) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 text-sm",
beyaz ? "text-white/90" : "text-slate-600",
)}
>
<Trophy
className={cn("size-4 shrink-0", beyaz ? "text-white" : "text-primary")}
aria-hidden
/>
<span>
Sıralaman kayıtlı:{" "}
<strong
className={cn(
"font-semibold tabular-nums",
beyaz ? "text-white" : "text-slate-900",
)}
>
{sira.toLocaleString("tr-TR")}.
</strong>{" "}
sıra · {turEtiketi(tur)}
</span>
</span>
);
}
/**
* 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-5 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="pl-12"
/>
</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. Sıra adımında başlık kanonik
// alt sayfa düzeninde (DESIGN.md §4.10: ortalı başlık + tek cümle bağlam,
// rozet yok); sihirbaz adımlarında başlıklar adımların kendisinde, çekmece
// başlığı yalnız ekran okuyucuya.
const siraAdimi = !kompakt && adim === "sira";
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"
>
<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"
>
<DrawerTitle
className={
siraAdimi
? "px-10 text-center font-heading text-2xl font-bold text-slate-900"
: "sr-only"
}
>
Sıralamana yakın programlar hangileri?
</DrawerTitle>
<DrawerDescription
className={
siraAdimi
? "mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500"
: "sr-only"
}
>
Puan türünü seç, sıralamanı yaz; hayal, dengeli ve güvenli
dilimlerindeki programları gerçek YÖK Atlas verisiyle bu sayfadan
ayrılmadan gör. Ücretsiz, üyelik gerekmez.
</DrawerDescription>
{siraAdimi ? (
<div className="mt-5">{siraFormu}</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 (başlığıyla birlikte) aynı ızgara hücresine
// bindirilir; hücrenin yüksekliğini her iki durumda da başlık + form belirler.
// Profilli içerik bu yüksekliği aşmayacak kadar kısa tutulmalı.
const kompaktBaslik = baslik ?? "Sıralaman bu tabanların neresinde?";
return (
<section
aria-labelledby={baslikId}
className={cn(
"rounded-2xl border p-4 transition-colors duration-200 sm:p-5",
profil ? "border-primary/20 bg-primary/5" : "border-slate-200 bg-white",
)}
>
<div className="grid">
<div
ref={profil ? undefined : formRef}
inert={profil ? true : undefined}
className={cn("col-start-1 row-start-1", profil && "invisible")}
>
<p className="text-sm leading-6 text-slate-600">
<strong
id={profil ? undefined : baslikId}
className="font-heading text-base font-bold text-slate-900"
>
{kompaktBaslik}
</strong>{" "}
Ücretsiz, üyelik gerekmez.
</p>
<div className="mt-3">{siraFormu}</div>
</div>
{profil ? (
<div className="col-start-1 row-start-1 flex flex-col items-center justify-center gap-3 text-center sm:flex-row sm:justify-between sm:gap-6 sm:text-left">
<div className="flex min-w-0 flex-col items-center gap-1 sm:items-start">
<p
id={baslikId}
className="font-heading text-base font-bold leading-6 text-slate-900"
>
{kompaktBaslik}
</p>
<KayitliSira sira={profil.sira} tur={profil.tur} />
</div>
<PagePixelDivider seed={67} className="w-24 sm:hidden" />
<Button asChild size="lg" className={cn(PROFILLI_CTA_SINIF, "w-full sm:w-auto")}>
<Link href={sonucHref()}>
Sıralamana uygun programları gör
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</div>
) : null}
</div>
{cekmece}
</section>
);
}
// Kart varyantı sayfa sonundaki CTA bandıdır (DESIGN.md §10.8, CtaBandi).
if (profil) {
return (
<CtaBandi seed={5}>
<h2 className="font-heading text-2xl font-bold text-balance sm:text-3xl">
{baslik ?? "Sıralamana yakın programlar hangileri? Saniyede gör."}
</h2>
<div className="mt-3">
<KayitliSira sira={profil.sira} tur={profil.tur} beyaz />
</div>
<p className="mx-auto mt-2 max-w-xl text-base leading-relaxed text-white/90">
Gerçek YÖK Atlas verisiyle hayal, dengeli ve güvenli dilimlerindeki
programların hazır.
</p>
<Button
asChild
size="lg"
className={cn(PROFILLI_CTA_SINIF, "mt-6 w-full active:scale-[0.97] sm:w-auto")}
>
<Link href={sonucHref()}>
Sıralamana uygun programları gör
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</CtaBandi>
);
}
return (
<div ref={formRef}>
<CtaBandi seed={9}>
<h2 className="font-heading text-2xl font-bold text-balance sm:text-3xl">
{baslik ?? "Sıralamana yakın programlar hangileri? Saniyede gör."}
</h2>
<p className="mx-auto mt-3 max-w-xl text-base leading-relaxed text-white/90">
{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"
size="lg"
onClick={() => setAcik(true)}
onPointerEnter={preloadSihirbazAdimlar}
onFocus={preloadSihirbazAdimlar}
className="mt-6 h-12 w-full cursor-pointer bg-orange-500 px-6 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97] sm:w-auto"
>
Sıralamanı gir, programları gör
<ArrowRight className="size-4" aria-hidden />
</Button>
</CtaBandi>
{cekmece}
</div>
);
}