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/lib/analitik.ts b/src/lib/analitik.ts index 2ed81cb..c60c552 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. @@ -48,16 +49,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.