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/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..9a45188 100644
--- a/src/features/kullanici/components/user-nav.tsx
+++ b/src/features/kullanici/components/user-nav.tsx
@@ -48,7 +48,7 @@ export async function UserNav() {
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.