Integrate Rybbit analytics and enhance user tracking features
All checks were successful
Deploy / deploy (push) Successful in 16m7s
All checks were successful
Deploy / deploy (push) Successful in 16m7s
- Added Rybbit analytics configuration in .mcp.json for improved data collection. - Updated layout.tsx to conditionally load the Rybbit analytics script in production. - Enhanced user interaction tracking in various components, including GirisForm, RevizyonKutusu, and SatinAlForm, by logging specific events. - Updated privacy policy page to reflect new data handling practices and added a section on service providers and data transfer. - Improved user navigation and experience by integrating Rybbit's live visitor widget in the site footer.
This commit is contained in:
53
src/lib/analitik-sunucu.ts
Normal file
53
src/lib/analitik-sunucu.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Rybbit'e sunucudan olay gönderimi (POST /api/track). Yalnızca sunucu
|
||||
* tarafından çağrılır (lib/odeme.ts) — anahtar NEXT_PUBLIC_ değil.
|
||||
*
|
||||
* Neden sunucu tarafı: ödeme dönüşümü tarayıcıdan gönderilirse reklam
|
||||
* engelleyiciler ve iyzico dönüşünde kapatılan sekmeler yüzünden kaybolur.
|
||||
* Buradan giden kayıt gerçek geliri taşır ve her zaman düşer.
|
||||
*
|
||||
* Env yoksa (dev, self-host anahtarı tanımsız) hiçbir istek yapılmaz.
|
||||
*/
|
||||
|
||||
const HOST = process.env.RYBBIT_HOST;
|
||||
const SITE_ID = process.env.RYBBIT_SITE_ID;
|
||||
const API_KEY = process.env.RYBBIT_API_KEY;
|
||||
|
||||
export type SunucuOlayAdi = "odeme_tamamlandi";
|
||||
|
||||
export async function sunucuOlayi(
|
||||
ad: SunucuOlayAdi,
|
||||
{
|
||||
kullaniciId,
|
||||
pathname = "/",
|
||||
ozellikler,
|
||||
}: {
|
||||
kullaniciId?: string;
|
||||
pathname?: string;
|
||||
ozellikler?: Record<string, string | number>;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
if (!HOST || !SITE_ID || !API_KEY) return;
|
||||
|
||||
try {
|
||||
await fetch(`${HOST}/api/track`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
site_id: SITE_ID,
|
||||
type: "custom_event",
|
||||
event_name: ad,
|
||||
pathname,
|
||||
...(kullaniciId ? { user_id: kullaniciId } : {}),
|
||||
...(ozellikler ? { properties: JSON.stringify(ozellikler) } : {}),
|
||||
}),
|
||||
// Analitik ödeme akışını asla geciktirmesin/bloklamasın
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
} catch {
|
||||
// Yut: analitik hatası ödeme sonucunu etkilemez
|
||||
}
|
||||
}
|
||||
91
src/lib/analitik.ts
Normal file
91
src/lib/analitik.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 sessizce hiçbir şey yapmaz — ç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"
|
||||
| "liste_olusturuldu"
|
||||
| "liste_revize"
|
||||
| "program_eklendi"
|
||||
| "danisman_sorusu"
|
||||
| "katalog_arama"
|
||||
| "odeme_baslatildi";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Özel olay gönderir. Script yoksa no-op. */
|
||||
export function olay(ad: OlayAdi, ozellikler?: Ozellikler): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.rybbit?.event(ad, ozellikler);
|
||||
} catch {
|
||||
// Analitik hiçbir koşulda kullanıcı akışını bozmaz
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export function kullaniciTanit(
|
||||
kullaniciId: string,
|
||||
traits?: Record<string, string>,
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.rybbit?.identify(kullaniciId, traits);
|
||||
} catch {
|
||||
// yut
|
||||
}
|
||||
}
|
||||
|
||||
/** Çıkışta kimliği temizler. */
|
||||
export function kullaniciTemizle(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.rybbit?.clearUserId();
|
||||
} catch {
|
||||
// yut
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { magicLink } from "better-auth/plugins";
|
||||
import { appDb, schema } from "./appdb";
|
||||
import { grantCredits, DENEME_KREDISI } from "./credits";
|
||||
import { magicLinkEpostasi } from "./eposta";
|
||||
|
||||
async function sendMagicLinkEmail(email: string, url: string) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
@@ -17,11 +18,15 @@ async function sendMagicLinkEmail(email: string, url: string) {
|
||||
}
|
||||
const { Resend } = await import("resend");
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
const { subject, html, text } = magicLinkEpostasi(url);
|
||||
await resend.emails.send({
|
||||
from: process.env.EMAIL_FROM ?? "KolayTercih <onboarding@resend.dev>",
|
||||
to: email,
|
||||
subject: "KolayTercih giriş bağlantın",
|
||||
html: `<p>Merhaba,</p><p>KolayTercih'e giriş yapmak için <a href="${url}">buraya tıkla</a>. Bağlantı 5 dakika geçerlidir.</p><p>Bu isteği sen yapmadıysan bu e-postayı yok sayabilirsin.</p>`,
|
||||
subject,
|
||||
html,
|
||||
// Düz metin alternatifi spam puanını düşürür ve HTML'i engelleyen
|
||||
// istemcilerde bağlantının okunabilir kalmasını sağlar.
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
166
src/lib/eposta.ts
Normal file
166
src/lib/eposta.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* E-posta şablonları.
|
||||
*
|
||||
* Kurallar: mail istemcileri harici CSS, <style> kuralı, flexbox ve SVG
|
||||
* desteğini güvenilir biçimde sunmaz — bu yüzden düzen tablo tabanlı ve tüm
|
||||
* stiller satır içi. Logo işareti de SVG yerine renkli hücrelerle yeniden
|
||||
* çizilir (Outlook masaüstü border-radius'u yok sayar; kareler köşeli görünür,
|
||||
* kabul edilebilir bozulma).
|
||||
*/
|
||||
|
||||
const MAVI = "#3b82f6";
|
||||
const SEFTALI = "#fdc7a7";
|
||||
const TURUNCU = "#ff5a00";
|
||||
const LACIVERT = "#0f172b";
|
||||
const GRI = "#475569";
|
||||
const KENAR = "#e2e8f0";
|
||||
const ZEMIN = "#f8fafc";
|
||||
|
||||
const YAZI_TIPI =
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
|
||||
|
||||
export type EpostaIcerik = {
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
/** HTML bağlamına gömülen değerleri kaçırır — token'lı URL'lerdeki & şart. */
|
||||
function kacir(deger: string): string {
|
||||
return deger
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Logo işareti: üç yuvarlak kare + KolayTercih kelime markası. */
|
||||
function baslik(): string {
|
||||
return `
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="padding-right:8px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td width="19" height="19" style="width:19px;height:19px;background-color:${MAVI};border-radius:5px;font-size:0;line-height:0;"> </td>
|
||||
<td width="4" style="width:4px;font-size:0;line-height:0;"> </td>
|
||||
<td>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td width="18" height="18" style="width:18px;height:18px;background-color:${SEFTALI};border-radius:5px;font-size:0;line-height:0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="4" style="height:4px;font-size:0;line-height:0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="18" height="18" style="width:18px;height:18px;background-color:${TURUNCU};border-radius:5px;font-size:0;line-height:0;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td style="font-family:${YAZI_TIPI};font-size:20px;letter-spacing:-0.5px;color:${LACIVERT};">
|
||||
<span style="font-weight:300;">Kolay</span><span style="font-weight:600;">Tercih</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tüm e-postaların ortak kabuğu: dış zemin, beyaz kart, logo ve alt bilgi.
|
||||
* `onizleme` gelen kutusunda konunun yanında görünen gizli özet metindir.
|
||||
*/
|
||||
function kabuk({
|
||||
onizleme,
|
||||
govde,
|
||||
}: {
|
||||
onizleme: string;
|
||||
govde: string;
|
||||
}): string {
|
||||
return `<!doctype html>
|
||||
<html lang="tr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>KolayTercih</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:${ZEMIN};">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;font-size:1px;line-height:1px;">${onizleme}</div>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:${ZEMIN};">
|
||||
<tr>
|
||||
<td align="center" style="padding:32px 16px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:520px;">
|
||||
<tr>
|
||||
<td style="padding-bottom:20px;">${baslik()}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#ffffff;border:1px solid ${KENAR};border-radius:16px;padding:32px;font-family:${YAZI_TIPI};font-size:15px;line-height:1.65;color:${GRI};">
|
||||
${govde}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px 8px 0;font-family:${YAZI_TIPI};font-size:12px;line-height:1.6;color:#94a3b8;">
|
||||
KolayTercih · YKS tercih listeni dakikalar içinde hazırla<br>
|
||||
Bu e-postayı hesabınla ilgili bir işlem yaptığın için aldın.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Ortalanmış, tablo tabanlı CTA butonu (Outlook'ta da zemin rengi tutar). */
|
||||
function buton(url: string, etiket: string): string {
|
||||
return `
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center" style="background-color:${MAVI};border-radius:10px;">
|
||||
<a href="${url}" style="display:inline-block;padding:14px 28px;font-family:${YAZI_TIPI};font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;">${etiket} →</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
export function magicLinkEpostasi(url: string): EpostaIcerik {
|
||||
const guvenliUrl = kacir(url);
|
||||
const govde = `
|
||||
<h1 style="margin:0 0 12px;font-family:${YAZI_TIPI};font-size:22px;line-height:1.35;font-weight:700;color:${LACIVERT};">
|
||||
Giriş bağlantın hazır
|
||||
</h1>
|
||||
<p style="margin:0 0 24px;">
|
||||
Aşağıdaki butona tıklayarak KolayTercih hesabına giriş yapabilirsin.
|
||||
Bağlantı <strong style="color:${LACIVERT};">5 dakika</strong> geçerli ve
|
||||
yalnızca bir kez kullanılabilir.
|
||||
</p>
|
||||
${buton(guvenliUrl, "Giriş yap")}
|
||||
<p style="margin:24px 0 0;font-size:13px;">
|
||||
Buton çalışmazsa bu adresi tarayıcının adres çubuğuna yapıştır:<br>
|
||||
<a href="${guvenliUrl}" style="color:${MAVI};word-break:break-all;">${guvenliUrl}</a>
|
||||
</p>
|
||||
<div style="margin:24px 0 0;padding-top:20px;border-top:1px solid ${KENAR};font-size:13px;color:#94a3b8;">
|
||||
Bu isteği sen yapmadıysan bu e-postayı yok sayabilirsin; bağlantıya
|
||||
tıklanmadığı sürece hesabında hiçbir şey değişmez.
|
||||
</div>`;
|
||||
|
||||
return {
|
||||
subject: "KolayTercih giriş bağlantın",
|
||||
html: kabuk({
|
||||
onizleme: "Giriş bağlantın 5 dakika geçerli.",
|
||||
govde,
|
||||
}),
|
||||
text: [
|
||||
"KolayTercih giriş bağlantın",
|
||||
"",
|
||||
"Hesabına giriş yapmak için bu adresi tarayıcında aç:",
|
||||
url,
|
||||
"",
|
||||
"Bağlantı 5 dakika geçerli ve yalnızca bir kez kullanılabilir.",
|
||||
"Bu isteği sen yapmadıysan bu e-postayı yok sayabilirsin.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { and, eq } from "drizzle-orm";
|
||||
import { appDb, schema } from "./appdb";
|
||||
import { grantCredits, URUNLER } from "./credits";
|
||||
import { retrieveCheckoutForm } from "./iyzico";
|
||||
import { sunucuOlayi } from "./analitik-sunucu";
|
||||
|
||||
const { orders } = schema;
|
||||
|
||||
@@ -57,6 +58,18 @@ export async function odemeyiSonuclandir(
|
||||
refId: order.id,
|
||||
setHasPaket: order.product === "paket",
|
||||
});
|
||||
|
||||
// Dönüşüm kaydı. Koşullu geçişin ardında olduğu için sipariş başına tam bir
|
||||
// kez düşer (self-healing fallback tekrar çağrılsa da rowsAffected 0 döner).
|
||||
await sunucuOlayi("odeme_tamamlandi", {
|
||||
kullaniciId: order.userId,
|
||||
pathname: "/odeme/sonuc",
|
||||
ozellikler: {
|
||||
urun: order.product,
|
||||
kredi: order.credits,
|
||||
tutar: order.amountKurus / 100,
|
||||
},
|
||||
});
|
||||
return "paid";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user