diff --git a/src/app/paket/layout.tsx b/src/app/paket/layout.tsx new file mode 100644 index 0000000..7620f3e --- /dev/null +++ b/src/app/paket/layout.tsx @@ -0,0 +1,16 @@ +import { PaketGorunumOlayi } from "@/features/odeme/components/paket-gorunum-olayi"; + +// Yalnızca ölçüm için: sayfanın kendisine dokunmadan paket_goruntulendi +// olayını bağlar (bkz. paket-gorunum-olayi.tsx). +export default function PaketLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + {children} + + + ); +} diff --git a/src/components/rybbit-identify.tsx b/src/components/rybbit-identify.tsx index 8d96748..7ebeb9e 100644 --- a/src/components/rybbit-identify.tsx +++ b/src/components/rybbit-identify.tsx @@ -12,24 +12,19 @@ import { kullaniciTanit, kullaniciTemizle } from "@/lib/analitik"; export function RybbitIdentify({ kullaniciId, - eposta, - isim, }: { /** null = oturum yok; o cihazdaki kimlik temizlenir (çıkış sonrası) */ kullaniciId: string | null; - eposta?: string | null; - isim?: string | null; }) { + // Yalnızca opak kullanıcı ID'si gider: e-posta/isim gibi kişisel veri + // üçüncü taraf analitiğe trait olarak GÖNDERİLMEZ. useEffect(() => { if (!kullaniciId) { kullaniciTemizle(); return; } - const traits: Record = {}; - if (eposta) traits.email = eposta; - if (isim) traits.name = isim; - kullaniciTanit(kullaniciId, traits); - }, [kullaniciId, eposta, isim]); + kullaniciTanit(kullaniciId); + }, [kullaniciId]); return null; } diff --git a/src/components/use-gorunum-olayi.ts b/src/components/use-gorunum-olayi.ts index 53739f1..a1df9c4 100644 --- a/src/components/use-gorunum-olayi.ts +++ b/src/components/use-gorunum-olayi.ts @@ -1,54 +1,60 @@ "use client"; -import { useEffect, useRef, type RefObject } from "react"; -import { olay, type OlayAdi, type Ozellikler } from "@/lib/analitik"; +import { useCallback, useEffect, useRef, type RefCallback } from "react"; +import { + olayAnahtari, + olayBirKez, + 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. + * anahtarıyla yapılır (bkz. olayBirKez). 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. + * + * Callback ref döner (RefObject değil): gözlem effect'te değil, DOM düğümü + * bağlandığı anda kurulur. Eleman ilk commit'te yoksa (kaydet-banner: mount + * sonrası açılır, üstelik portal içinde) ya da sökülüp yeni bir düğümle geri + * geliyorsa (tadımlık satırı: tip uymayınca gizlenip fetch sonrası yeniden + * takılır) useRef + effect deseni gözlemi hiç kurmuyordu. */ export function useGorunumOlayi( ad: OlayAdi, ozellikler?: Ozellikler, /** Aynı olayın farklı yüzeyleri için tekilleştirme eki ("satir", "kart"). */ anahtarEki?: string, -): RefObject { - const ref = useRef(null); - // Özellikler her render sonrası tazelenir; observer effect'i yalnızca - // ad/anahtar değişiminde kurulur (deps'e obje koymamak için ref). +): RefCallback { + // Özellikler her render sonrası tazelenir; callback ref yalnızca ad/anahtar + // değişiminde yeniden 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; + return useCallback( + (el: T | null) => { + 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"); + if (window.sessionStorage.getItem(olayAnahtari(ad, anahtarEki))) return; } catch { - // yut + // Storage engelliyse tekilleştirmeden vazgeç, olay yine atılabilir } - olay(ad, ozelliklerRef.current); - }); - io.observe(el); - return () => io.disconnect(); - }, [ad, anahtarEki]); - return ref; + const io = new IntersectionObserver((entries) => { + if (!entries.some((e) => e.isIntersecting)) return; + io.disconnect(); + olayBirKez(ad, ozelliklerRef.current, anahtarEki); + }); + io.observe(el); + // React 19 ref temizliği: düğüm sökülünce (ya da ad/anahtar değişince) + // gözlemci bırakılır; yeni düğüm bağlanınca callback baştan çalışır. + return () => io.disconnect(); + }, + [ad, anahtarEki], + ); } diff --git a/src/features/kullanici/components/giris-form.tsx b/src/features/kullanici/components/giris-form.tsx index 2a14fe6..6b51fd0 100644 --- a/src/features/kullanici/components/giris-form.tsx +++ b/src/features/kullanici/components/giris-form.tsx @@ -38,8 +38,10 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) { // Yeniden gönderim arası bekleme (sn) — istemeden spam'lenmesin const [bekleme, setBekleme] = useState(0); + // 60 sn: sunucunun adres başına gönderim soğumasıyla hizalı; daha kısa + // sayaçta "Yeniden gönder" sessizce yutulur, kullanıcı gelmeyen maili bekler. function geriSayimBaslat() { - setBekleme(30); + setBekleme(60); const sayac = setInterval(() => { setBekleme((s) => { if (s <= 1) { @@ -59,6 +61,7 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) { callbackURL, }); if (error) { + olay("giris_hata", { yontem: "google", status: error.status ?? 0 }); toast.error("Google ile giriş başarısız. Tekrar dener misin?"); setBusy(null); } @@ -81,7 +84,13 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) { }); setBusy(null); if (error) { - toast.error("Bağlantı gönderilemedi. Tekrar dener misin?"); + olay("giris_hata", { yontem: "eposta", status: error.status ?? 0 }); + // 429 = hız sınırı: "tekrar dene" demek sınırı daha da uzatır + toast.error( + error.status === 429 + ? "Çok sık denedin, bir dakika sonra tekrar dene" + : "Bağlantı gönderilemedi. Tekrar dener misin?", + ); return; } setSent(true); diff --git a/src/features/kullanici/components/user-nav.tsx b/src/features/kullanici/components/user-nav.tsx index 5b43f6a..2e16c79 100644 --- a/src/features/kullanici/components/user-nav.tsx +++ b/src/features/kullanici/components/user-nav.tsx @@ -44,11 +44,11 @@ export async function UserNav() { return (
- + diff --git a/src/features/liste/components/tercih-profili-kapisi.tsx b/src/features/liste/components/tercih-profili-kapisi.tsx index d9ca13a..7085430 100644 --- a/src/features/liste/components/tercih-profili-kapisi.tsx +++ b/src/features/liste/components/tercih-profili-kapisi.tsx @@ -163,6 +163,7 @@ function KapıIcerik({ sira_kovasi: siraKovasi(siraDeger), }); } catch { + olay("sira_form_hata", { kaynak: duzenle ? "duzenle" : "kapi" }); setHata("Bir şeyler ters gitti, tekrar dener misin?"); } finally { setYukleniyor(false); diff --git a/src/features/odeme/components/paket-gorunum-olayi.tsx b/src/features/odeme/components/paket-gorunum-olayi.tsx new file mode 100644 index 0000000..8d9a172 --- /dev/null +++ b/src/features/odeme/components/paket-gorunum-olayi.tsx @@ -0,0 +1,25 @@ +"use client"; + +// /paket görüntülenmesini ve ziyaretçiyi oraya getiren yüzeyi ölçer. +// +// Neden ayrı bileşen + layout: /paket sayfası sunucu bileşeni ve statik +// kalmalı. `useSearchParams` kullanılmaz — cacheComponents açıkken Suspense +// sınırı/CSR bailout gerektirir; parametre yalnızca ölçüm için lazım, mount +// sonrası window.location'dan okumak yeter. + +import { useEffect } from "react"; +import { olayBirKez } from "@/lib/analitik"; + +// Serbest metin analitiğe sızmasın: bilinmeyen değerler "diger"e düşer. +const KAYNAKLAR = ["kilit", "kredi_bitti", "nav"] as const; + +export function PaketGorunumOlayi() { + useEffect(() => { + const ham = new URLSearchParams(window.location.search).get("kaynak"); + const kaynak = KAYNAKLAR.find((k) => k === ham) ?? "diger"; + // Oturum başına kaynak başına 1 kez: yenileme sayıyı şişirmesin + olayBirKez("paket_goruntulendi", { kaynak }, kaynak); + }, []); + + return null; +} diff --git a/src/features/rapor/components/liste-uretici.tsx b/src/features/rapor/components/liste-uretici.tsx index b5f36dc..c355e0b 100644 --- a/src/features/rapor/components/liste-uretici.tsx +++ b/src/features/rapor/components/liste-uretici.tsx @@ -84,9 +84,11 @@ export function ListeUretici({ } else if (sonuc.code === "AUTH") { router.push(`/giris?callback=${encodeURIComponent("/listem?uret=1")}`); } else { + olay("liste_uretim_hata", { kod: sonuc.code }); setDurum({ ad: "hata", mesaj: sonuc.error }); } } catch { + olay("liste_uretim_hata", { kod: "ISTISNA" }); setDurum({ ad: "hata", mesaj: "Beklenmeyen bir hata oluştu, tekrar dener misin?", @@ -131,7 +133,7 @@ export function ListeUretici({ size="lg" className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600" > - + {hasPaket ? "+30 kredi yükle — 129 TL" : "Paketi aktive et — 299 TL"} diff --git a/src/features/rapor/components/listem-govde.tsx b/src/features/rapor/components/listem-govde.tsx index 026a86c..28feb08 100644 --- a/src/features/rapor/components/listem-govde.tsx +++ b/src/features/rapor/components/listem-govde.tsx @@ -285,7 +285,7 @@ export function ListemGovde({ asChild className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto" > - + Analizleri aç — {paketFiyati} TL @@ -366,7 +366,7 @@ export function ListemGovde({ size="lg" className="mt-5 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600" > - + Kişisel analizi aç diff --git a/src/features/sihirbaz/components/cta-sira-form.tsx b/src/features/sihirbaz/components/cta-sira-form.tsx index d35a757..6003c8f 100644 --- a/src/features/sihirbaz/components/cta-sira-form.tsx +++ b/src/features/sihirbaz/components/cta-sira-form.tsx @@ -21,6 +21,7 @@ import { DrawerTitle, } from "@/components/ui/drawer"; import { SectionEyebrow } from "@/components/pixel-decor"; +import { useGorunumOlayi } from "@/components/use-gorunum-olayi"; import { SihirbazAdimlarLazy, preloadSihirbazAdimlar, @@ -73,6 +74,10 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) { const [yukleniyor, setYukleniyor] = useState(false); const [hata, setHata] = useState(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. + const formRef = useGorunumOlayi("cta_form_goruntulendi"); // Yazarken binlik ayracıyla biçimle (85000 → 85.000) function siralamaDegisti(ham: string) { @@ -108,6 +113,7 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) { sira_kovasi: siraKovasi(value), }); } catch { + olay("sira_form_hata", { kaynak: "cta" }); setHata("Bir şeyler ters gitti, tekrar dener misin?"); } finally { setYukleniyor(false); @@ -159,7 +165,10 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) { } return ( -
+

{baslik ?? "Sıralamanla nereye yerleşirsin? Saniyede gör."}

diff --git a/src/features/sihirbaz/components/hero-form.tsx b/src/features/sihirbaz/components/hero-form.tsx index 484746b..ec4f94e 100644 --- a/src/features/sihirbaz/components/hero-form.tsx +++ b/src/features/sihirbaz/components/hero-form.tsx @@ -85,6 +85,7 @@ export function HeroForm() { sira_kovasi: siraKovasi(value), }); } catch { + olay("sira_form_hata", { kaynak: "hero" }); setHata("Bir şeyler ters gitti, tekrar dener misin?"); } finally { setYukleniyor(false); diff --git a/src/features/sihirbaz/components/sihirbaz-cagri-karti.tsx b/src/features/sihirbaz/components/sihirbaz-cagri-karti.tsx index 2568f83..d5a3f90 100644 --- a/src/features/sihirbaz/components/sihirbaz-cagri-karti.tsx +++ b/src/features/sihirbaz/components/sihirbaz-cagri-karti.tsx @@ -9,7 +9,7 @@ import { useReducedMotion } from "motion/react"; import { Button } from "@/components/ui/button"; import { PixelField } from "@/components/pixel-decor"; import { useGorunumOlayi } from "@/components/use-gorunum-olayi"; -import { olay } from "@/lib/analitik"; +import { olay, olayBirKez, siraKovasi } from "@/lib/analitik"; import type { SihirbazFacetleri } from "@/types/yokatlas"; import { SIHIRBAZ_AC_EVENT, SIHIRBAZ_STORAGE_KEY, sihirbazDogrula, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler"; import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil"; @@ -82,6 +82,22 @@ export function SihirbazBolumu({ // görsün diye ref'te tutulur; olay dinleyicisi bir kez bağlanır. const bekleyenRef = useRef(null); + // Huni adımı: sonuç sayfası görüntülendi. URL sıralamayı taşıdığı için + // pathname maskeli (bkz. app/layout.tsx); huni bu olayla kurulur. Oturum + // başına girişli/anonim için birer kez — girişten dönen ziyaretçi ikinci + // kez "girisli" olarak sayılır. Ham sıra gönderilmez, yalnızca kovası. + useEffect(() => { + olayBirKez( + "sonuc_goruntulendi", + { + girisli: girisliMi ? "evet" : "hayir", + sira_kovasi: siraKovasi(sira), + }, + girisliMi ? "girisli" : "anonim", + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Mount sonrası storage'dan bekleyen seçimleri oku useEffect(() => { try { diff --git a/src/lib/analitik.ts b/src/lib/analitik.ts index 2ed81cb..b7dedb5 100644 --- a/src/lib/analitik.ts +++ b/src/lib/analitik.ts @@ -3,8 +3,9 @@ * * Script yalnızca prod build'de yükleniyor (bkz. app/layout.tsx) ve reklam * engelleyiciler tarafından bloklanabiliyor; bu yüzden buradaki her çağrı - * `window.rybbit` yoksa sessizce hiçbir şey yapmaz — çağıran taraf asla - * varlık kontrolü yapmak zorunda kalmasın. + * `window.rybbit` yoksa kullanıcıya hiçbir şey hissettirmez (olaylar kısa + * süre kuyrukta bekler, script gelmezse atılır) — çağıran taraf asla varlık + * kontrolü yapmak zorunda kalmasın. * * KURAL: Öğrencinin ham başarı sıralaması analitiğe GÖNDERİLMEZ. Sıralama * her zaman `siraKovasi()` ile bantlanır. @@ -29,7 +30,13 @@ export type OlayAdi = | "kilit_goruntulendi" | "kredi_bitti" | "tadimlik_goruntulendi" - | "kaydet_banner_goruntulendi"; + | "kaydet_banner_goruntulendi" + | "cta_form_goruntulendi" + | "sira_form_hata" + | "sonuc_goruntulendi" + | "paket_goruntulendi" + | "liste_uretim_hata" + | "giris_hata"; export type Ozellikler = Record; @@ -48,16 +55,100 @@ declare global { } } -/** Özel olay gönderir. Script yoksa no-op. */ +// Erken olay kuyruğu: script `afterInteractive` yüklendiği için sert sayfa +// yüklemesinde (OAuth dönüşü, e-postadaki link, yer imi) mount effect'leri +// `window.rybbit`'ten önce koşar. Mount anında atılan olaylar +// (sonuc_goruntulendi, paket_goruntulendi) kaybolmasın diye script gelene +// kadar burada bekletilir. Script hiç gelmezse (reklam engelleyici, dev) +// kuyruk 10 sn sonra sessizce atılır ve bir daha doldurulmaz. +const KUYRUK_SINIRI = 20; +const DENEME_ARALIGI_MS = 500; +const DENEME_SINIRI = 20; + +let kuyruk: Array<[OlayAdi, Ozellikler | undefined]> = []; +let zamanlayici: ReturnType | null = null; +let denemeSayisi = 0; +let kuyrukKapali = false; + +function zamanlayiciyiDurdur(): void { + if (zamanlayici !== null) clearInterval(zamanlayici); + zamanlayici = null; +} + +/** Bekleyen olayları geliş sırasıyla gönderir. */ +function kuyruguBosalt(rybbit: RybbitApi): void { + const bekleyenler = kuyruk; + kuyruk = []; + zamanlayiciyiDurdur(); + for (const [ad, ozellikler] of bekleyenler) { + try { + rybbit.event(ad, ozellikler); + } catch { + // yut + } + } +} + +function kuyrugaEkle(ad: OlayAdi, ozellikler?: Ozellikler): void { + if (kuyrukKapali || kuyruk.length >= KUYRUK_SINIRI) return; + kuyruk.push([ad, ozellikler]); + if (zamanlayici !== null) return; + zamanlayici = setInterval(() => { + denemeSayisi += 1; + if (window.rybbit) { + kuyruguBosalt(window.rybbit); + } else if (denemeSayisi >= DENEME_SINIRI) { + // Script gelmedi: bekleyenleri at, sonraki olaylar eski no-op'a dönsün + kuyruk = []; + kuyrukKapali = true; + zamanlayiciyiDurdur(); + } + }, DENEME_ARALIGI_MS); +} + +/** Özel olay gönderir. Script henüz yoksa kısa süre kuyrukta bekletir. */ export function olay(ad: OlayAdi, ozellikler?: Ozellikler): void { if (typeof window === "undefined") return; try { - window.rybbit?.event(ad, ozellikler); + if (!window.rybbit) { + kuyrugaEkle(ad, ozellikler); + return; + } + // Sıra bozulmasın: bekleyen varsa önce onlar gider + if (kuyruk.length > 0) kuyruguBosalt(window.rybbit); + window.rybbit.event(ad, ozellikler); } catch { // Analitik hiçbir koşulda kullanıcı akışını bozmaz } } +/** Oturum başına tekilleştirme anahtarı (useGorunumOlayi ile ortak). */ +export function olayAnahtari(ad: OlayAdi, anahtarEki?: string): string { + return `kolaytercih.olay.${ad}${anahtarEki ? `.${anahtarEki}` : ""}`; +} + +/** + * Olayı oturum (sekme) başına en fazla 1 kez gönderir; "görüntülendi" türü + * olaylar yenileme ve geri-ileri gezinmede şişmesin diye. Tekilleştirme + * sessionStorage anahtarıyla yapılır. + */ +export function olayBirKez( + ad: OlayAdi, + ozellikler?: Ozellikler, + /** Aynı olayın farklı yüzeyleri/durumları için tekilleştirme eki. */ + anahtarEki?: string, +): void { + if (typeof window === "undefined") return; + const anahtar = olayAnahtari(ad, anahtarEki); + try { + if (window.sessionStorage.getItem(anahtar)) return; + window.sessionStorage.setItem(anahtar, "1"); + } catch { + // Storage engelliyse tekilleştirmeden vazgeç, olay yine atılır + } + olay(ad, ozellikler); +} + /** * Ham sıralamayı analitik için bantlar. Panelde "hangi sıralama bandı * dönüşüyor" sorusunu yanıtlar ama tekil öğrenciyi işaret etmez.