Files
kolaytercih/src/lib/analitik.ts
bilalgursen f60fbd135d feat(olcum): liste_uretim_basladi olayı — üretim denemesinin paydası
Bugüne kadar yalnız başarı (liste_olusturuldu) ölçülüyordu; kaç kişinin
üretimi denediği bilinmiyordu. /listem?uret=1 ile üretim gövdesi mount
olduğunda yeni bileşen `liste_uretim_basladi` atar.

Olay bilerek liste-uretici.tsx'e değil, listem-icerik.tsx'te ona kardeş
mount edilen yeni bir istemci bileşenine kondu (o dosya bu turda başka bir
işin altında). İkisi aynı `uret` dalında render edildiği için koşulları
birebir aynı.

`kaynak` URL'den, yani kullanıcı denetiminden geldiği için allowlist'ten
geçirilir; listede olmayan değer "bilinmiyor" raporlanır. Giriş
round-trip'inde değerin kaybolmaması için geriYol da kaynağı taşır —
`uret=1` her zaman ilk parametre kalır (giris-karti.tsx:31 sözleşmesi).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:18:15 +03:00

237 lines
8.0 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.
/**
* Rybbit analitiği için ince sarmalayıcı.
*
* 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 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.
*/
/** Ölçtüğümüz huni olayları. Yeni olay eklerken bu birliği genişlet. */
export type OlayAdi =
| "sira_girildi"
| "sihirbaz_adim"
| "sihirbaz_tamamlandi"
| "giris_denendi"
// Üretim denemesinin paydası: /listem?uret=1 ile üretim gövdesi mount
// olduğunda atılır (sihirbaz, hazır panel CTA'sı, giriş dönüşü, boş durum
// CTA'sı ve ödeme sonrası kartı — hangisi olduğu `kaynak` özelliğinde).
// Bilerek `olayBirKez` KULLANILMAZ: aynı oturumdaki ikinci deneme de
// sayılmalı, yoksa payda başarıdan (liste_olusturuldu) küçük kalır.
| "liste_uretim_basladi"
| "liste_olusturuldu"
| "liste_guncellendi"
| "liste_revize"
// Seri kırılması: bu düzeltmenin canlıya çıktığı tarihten önce = +
// tıklaması (kapı açılsa da atılıyordu), sonrası = gerçek ekleme
// ({yol: "dogrudan" | "kapi"}); seriler karşılaştırılamaz.
| "program_eklendi"
| "netler_goruntulendi"
| "danisman_sorusu"
| "katalog_arama"
| "odeme_baslatildi"
| "hazir_panel_goruntulendi"
| "giris_cta_tiklandi"
| "kilit_goruntulendi"
| "kredi_bitti"
| "tadimlik_goruntulendi"
| "kaydet_banner_goruntulendi"
| "cta_form_goruntulendi"
| "sira_form_hata"
| "sonuc_goruntulendi"
| "paket_goruntulendi"
| "liste_uretim_hata"
| "giris_hata"
// Profil kapısı (sıra + sihirbaz modalı). kapi_acildi {tetik}: her açılış.
// kapi_kapatildi {adim, tetik}: yalnız TAMAMLANMADAN kapanış (X, dışarı
// tıklama, Vazgeç); tamamlanan kapıda atılmaz.
| "kapi_acildi"
| "kapi_kapatildi";
export type Ozellikler = Record<string, string | number>;
interface RybbitApi {
event: (ad: string, ozellikler?: Ozellikler) => void;
pageview: () => void;
identify: (kullaniciId: string, traits?: Record<string, string>) => void;
setTraits: (traits: Record<string, string>) => void;
clearUserId: () => void;
getUserId: () => string | null;
}
declare global {
interface Window {
rybbit?: RybbitApi;
}
}
// 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]> = [];
// Script'ten önce gelen identify için tek "son değer" slotu: sert yüklemede
// (OAuth / e-posta linki dönüşü) RybbitIdentify effect'i de script'ten önce
// koşar. Kuyruk değil slot — yalnız en son kimlik anlamlıdır.
let bekleyenKimlik: [string, Record<string, string> | undefined] | null = null;
let zamanlayici: ReturnType<typeof setInterval> | null = null;
let denemeSayisi = 0;
let kuyrukKapali = false;
function zamanlayiciyiDurdur(): void {
if (zamanlayici !== null) clearInterval(zamanlayici);
zamanlayici = null;
}
/** Önce bekleyen kimliği uygular, sonra olayları geliş sırasıyla gönderir
* (kuyruktaki olaylar anonim profile değil tanımlı kullanıcıya yazılsın). */
function kuyruguBosalt(rybbit: RybbitApi): void {
const bekleyenler = kuyruk;
const kimlik = bekleyenKimlik;
kuyruk = [];
bekleyenKimlik = null;
zamanlayiciyiDurdur();
if (kimlik) {
try {
rybbit.identify(kimlik[0], kimlik[1]);
} catch {
// yut
}
}
for (const [ad, ozellikler] of bekleyenler) {
try {
rybbit.event(ad, ozellikler);
} catch {
// yut
}
}
}
/** Script'i bekleyen yoklamayı başlatır (olay kuyruğu ve kimlik slotu ortak). */
function zamanlayiciyiBaslat(): void {
if (zamanlayici !== null) return;
zamanlayici = setInterval(() => {
denemeSayisi += 1;
if (window.rybbit) {
kuyruguBosalt(window.rybbit);
} else if (denemeSayisi >= DENEME_SINIRI) {
// Script gelmedi: bekleyenleri at, sonraki çağrılar eski no-op'a dönsün
kuyruk = [];
bekleyenKimlik = null;
kuyrukKapali = true;
zamanlayiciyiDurdur();
}
}, DENEME_ARALIGI_MS);
}
function kuyrugaEkle(ad: OlayAdi, ozellikler?: Ozellikler): void {
if (kuyrukKapali || kuyruk.length >= KUYRUK_SINIRI) return;
kuyruk.push([ad, ozellikler]);
zamanlayiciyiBaslat();
}
/** Ö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 {
if (!window.rybbit) {
kuyrugaEkle(ad, ozellikler);
return;
}
// Sıra bozulmasın: bekleyen (kimlik ya da olay) varsa önce onlar gider
if (kuyruk.length > 0 || bekleyenKimlik) 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.
*/
export function siraKovasi(sira: number): string {
if (sira <= 10_000) return "0-10 bin";
if (sira <= 50_000) return "10-50 bin";
if (sira <= 100_000) return "50-100 bin";
if (sira <= 250_000) return "100-250 bin";
if (sira <= 500_000) return "250-500 bin";
return "500 bin+";
}
/**
* Giriş yapan kullanıcıyı eşler. İlk çağrıda o cihazdaki önceki anonim
* olaylar geriye dönük olarak bu kullanıcıya bağlanır (sihirbazı doldurup
* sonra giriş yapan öğrencinin yolculuğu tek profilde birleşsin diye).
* Script henüz yoksa kimlik tek slotta bekler; kuyruk boşaltılırken
* olaylardan ÖNCE uygulanır.
*/
export function kullaniciTanit(
kullaniciId: string,
traits?: Record<string, string>,
): void {
if (typeof window === "undefined") return;
try {
if (!window.rybbit) {
if (kuyrukKapali) return;
bekleyenKimlik = [kullaniciId, traits];
zamanlayiciyiBaslat();
return;
}
// Doğrudan uygulanır; kuyrukta olay bekliyorsa sonraki boşaltmada bu
// kimlikle gider. Eski slot değeri geçersiz kalmasın diye silinir.
bekleyenKimlik = null;
window.rybbit.identify(kullaniciId, traits);
} catch {
// yut
}
}
/** Çıkışta kimliği temizler (bekleyen kimlik slotu dahil). */
export function kullaniciTemizle(): void {
if (typeof window === "undefined") return;
bekleyenKimlik = null;
try {
window.rybbit?.clearUserId();
} catch {
// yut
}
}