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:
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
|
"rybbit": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://rybbit.kolaytercih.com/api/mcp"
|
||||||
|
},
|
||||||
"shadcn": {
|
"shadcn": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { Mail } from "lucide-react";
|
import { Mail } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { olay } from "@/lib/analitik";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
|
|||||||
|
|
||||||
async function googleIleGiris() {
|
async function googleIleGiris() {
|
||||||
setBusy("google");
|
setBusy("google");
|
||||||
|
olay("giris_denendi", { yontem: "google" });
|
||||||
const { error } = await authClient.signIn.social({
|
const { error } = await authClient.signIn.social({
|
||||||
provider: "google",
|
provider: "google",
|
||||||
callbackURL,
|
callbackURL,
|
||||||
@@ -80,6 +82,7 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
|
|||||||
}
|
}
|
||||||
setSent(true);
|
setSent(true);
|
||||||
geriSayimBaslat();
|
geriSayimBaslat();
|
||||||
|
olay("giris_denendi", { yontem: "eposta" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sent) {
|
if (sent) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function GizlilikPage() {
|
|||||||
Gizlilik ve Kişisel Verilerin Korunması
|
Gizlilik ve Kişisel Verilerin Korunması
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
Son güncelleme: 26 Temmuz 2026
|
Son güncelleme: 2 Ağustos 2026
|
||||||
</p>
|
</p>
|
||||||
</Parallax>
|
</Parallax>
|
||||||
|
|
||||||
@@ -56,6 +56,25 @@ export default function GizlilikPage() {
|
|||||||
Verilerini üçüncü taraflara satmayız, reklam amacıyla paylaşmayız.
|
Verilerini üçüncü taraflara satmayız, reklam amacıyla paylaşmayız.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||||
|
Hizmet sağlayıcılar ve yurt dışına aktarım
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2">
|
||||||
|
Hizmeti çalıştırabilmek için birkaç dış sağlayıcıdan yararlanıyoruz
|
||||||
|
ve verinin bir kısmı yalnızca ilgili iş için bu sağlayıcılara
|
||||||
|
iletilir. Giriş bağlantısı ve bilgilendirme e-postaları Resend
|
||||||
|
üzerinden gönderilir; e-posta adresin bu amaçla Avrupa
|
||||||
|
Birliği’ndeki (İrlanda) sunucularda işlenir. Ödemeler,
|
||||||
|
Türkiye’de yerleşik iyzico altyapısında gerçekleşir. Danışman
|
||||||
|
sohbetine yazdığın mesajlar, yanıt üretilebilmesi için
|
||||||
|
Anthropic’in yapay zekâ servisine (ABD) iletilir; bu mesajlar
|
||||||
|
sağlayıcının yapay zekâ modellerini eğitmek için kullanılmaz.
|
||||||
|
KVKK açısından bunlardan e-posta ve yapay zekâ sağlayıcısına yapılan
|
||||||
|
iletim yurt dışına aktarım sayılır ve hizmetin sunulabilmesi için
|
||||||
|
zorunludur.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||||
Saklama ve silme
|
Saklama ve silme
|
||||||
@@ -71,11 +90,18 @@ export default function GizlilikPage() {
|
|||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||||
Çerezler
|
Çerezler ve analitik
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
Yalnızca oturumunu açık tutmak için zorunlu çerezler kullanılır;
|
Yalnızca oturumunu açık tutmak için zorunlu çerezler kullanılır;
|
||||||
reklam veya izleme çerezi kullanılmaz.
|
reklam veya izleme çerezi kullanılmaz. Siteyi geliştirebilmek için
|
||||||
|
hangi sayfaların görüntülendiğini ve hangi adımların tamamlandığını
|
||||||
|
ölçen, çerez kullanmayan bir analitik aracı (Rybbit) çalıştırıyoruz;
|
||||||
|
bu araç kendi sunucumuzda barındırılır, veriler üçüncü taraf bir
|
||||||
|
reklam ağına gitmez. Giriş yaptığında ziyaretin hesabınla
|
||||||
|
eşleştirilir. Başarı sıralaman analitiğe ham hâliyle
|
||||||
|
gönderilmez; yalnızca “50-100 bin” gibi geniş bir
|
||||||
|
aralık olarak kaydedilir.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</Parallax>
|
</Parallax>
|
||||||
|
|||||||
@@ -84,12 +84,19 @@ export default function RootLayout({
|
|||||||
// içerikle büyümeli, viewport tabanı body'deki min-h-dvh'den gelir.
|
// içerikle büyümeli, viewport tabanı body'deki min-h-dvh'den gelir.
|
||||||
className={`${calSans.variable} ${clarityCity.variable} ${geistMono.variable} ${bricolage.variable} antialiased`}
|
className={`${calSans.variable} ${clarityCity.variable} ${geistMono.variable} ${bricolage.variable} antialiased`}
|
||||||
>
|
>
|
||||||
{/* Rybbit analytics — beforeInteractive her zaman <head> içine defer olarak enjekte edilir */}
|
{/* Rybbit analitik — beforeInteractive her zaman <head> içine defer olarak
|
||||||
|
enjekte edilir. Yalnızca prod build'de: Rybbit localhost'u da takip
|
||||||
|
ettiği için dev trafiği prod verisini kirletmesin (DevPanel ile aynı
|
||||||
|
kapı). mask-patterns: /sonuc URL'i ham başarı sıralamasını,
|
||||||
|
/odeme/sonuc sipariş kimliğini taşıyor — path tek satırda toplanır. */}
|
||||||
|
{process.env.NODE_ENV === "production" ? (
|
||||||
<Script
|
<Script
|
||||||
src="https://rybbit.kolaytercih.com/api/script.js"
|
src="https://rybbit.kolaytercih.com/api/script.js"
|
||||||
data-site-id="f2cb042c657e"
|
data-site-id="f2cb042c657e"
|
||||||
|
data-mask-patterns='["/sonuc", "/odeme/sonuc"]'
|
||||||
strategy="beforeInteractive"
|
strategy="beforeInteractive"
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
<body className="min-h-dvh flex flex-col bg-slate-50">
|
<body className="min-h-dvh flex flex-col bg-slate-50">
|
||||||
<HeaderGate>
|
<HeaderGate>
|
||||||
{/* Site geneli progressive blur — üst ve alt */}
|
{/* Site geneli progressive blur — üst ve alt */}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { RefreshCcw } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { listeRevize } from "@/app/sonuc/actions";
|
import { listeRevize } from "@/app/sonuc/actions";
|
||||||
|
import { olay } from "@/lib/analitik";
|
||||||
|
|
||||||
export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) {
|
export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) {
|
||||||
const [feedback, setFeedback] = useState("");
|
const [feedback, setFeedback] = useState("");
|
||||||
@@ -38,6 +39,7 @@ export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) {
|
|||||||
requestId: crypto.randomUUID(),
|
requestId: crypto.randomUUID(),
|
||||||
});
|
});
|
||||||
if (sonuc.ok) {
|
if (sonuc.ok) {
|
||||||
|
olay("liste_revize", { sayfa: "listem" });
|
||||||
toast.success("Listen yeniden kuruldu.");
|
toast.success("Listen yeniden kuruldu.");
|
||||||
setFeedback("");
|
setFeedback("");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { PromptSuggestion } from "@/components/ui/prompt-suggestion";
|
|||||||
import { ScrollButton } from "@/components/ui/scroll-button";
|
import { ScrollButton } from "@/components/ui/scroll-button";
|
||||||
import { DotsLoader } from "@/components/ui/loader";
|
import { DotsLoader } from "@/components/ui/loader";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { olay } from "@/lib/analitik";
|
||||||
|
|
||||||
export interface Mesaj {
|
export interface Mesaj {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -119,6 +120,10 @@ export function SohbetClient({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setKredi((k) => k - 1);
|
setKredi((k) => k - 1);
|
||||||
|
// Bu bileşen /sonuc'taki popup'tan da kullanılıyor (sohbet-popup.tsx)
|
||||||
|
olay("danisman_sorusu", {
|
||||||
|
sayfa: location.pathname.startsWith("/listem") ? "listem" : "sonuc",
|
||||||
|
});
|
||||||
const reader = res.body!.getReader();
|
const reader = res.body!.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let birikmis = "";
|
let birikmis = "";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useActionState, useEffect } from "react";
|
|||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { olay } from "@/lib/analitik";
|
||||||
import { baslatOdeme, type OdemeBaslatDurum } from "./actions";
|
import { baslatOdeme, type OdemeBaslatDurum } from "./actions";
|
||||||
|
|
||||||
export function SatinAlForm({
|
export function SatinAlForm({
|
||||||
@@ -25,7 +26,10 @@ export function SatinAlForm({
|
|||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={formAction}>
|
<form
|
||||||
|
action={formAction}
|
||||||
|
onSubmit={() => olay("odeme_baslatildi", { urun })}
|
||||||
|
>
|
||||||
<input type="hidden" name="urun" value={urun} />
|
<input type="hidden" name="urun" value={urun} />
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
tercihProfiliYaz,
|
tercihProfiliYaz,
|
||||||
type SihirbazSecimleri,
|
type SihirbazSecimleri,
|
||||||
} from "@/lib/sihirbaz";
|
} from "@/lib/sihirbaz";
|
||||||
|
import { olay, siraKovasi } from "@/lib/analitik";
|
||||||
import { SihirbazModal } from "./sihirbaz-modal";
|
import { SihirbazModal } from "./sihirbaz-modal";
|
||||||
import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli";
|
import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli";
|
||||||
import { listeOlustur, listeRevize } from "./actions";
|
import { listeOlustur, listeRevize } from "./actions";
|
||||||
@@ -215,6 +216,7 @@ export function SihirbazBolumu({
|
|||||||
});
|
});
|
||||||
if (sonuc.ok) {
|
if (sonuc.ok) {
|
||||||
requestIdRef.current = null;
|
requestIdRef.current = null;
|
||||||
|
olay("liste_olusturuldu", { tur, sira_kovasi: siraKovasi(sira) });
|
||||||
setRapor({
|
setRapor({
|
||||||
rapor: sonuc.rapor,
|
rapor: sonuc.rapor,
|
||||||
params: sonuc.params,
|
params: sonuc.params,
|
||||||
@@ -392,6 +394,7 @@ export function SihirbazBolumu({
|
|||||||
requestId: crypto.randomUUID(),
|
requestId: crypto.randomUUID(),
|
||||||
});
|
});
|
||||||
if (sonuc.ok) {
|
if (sonuc.ok) {
|
||||||
|
olay("liste_revize", { sayfa: "sonuc" });
|
||||||
setRapor({
|
setRapor({
|
||||||
rapor: sonuc.rapor,
|
rapor: sonuc.rapor,
|
||||||
params: sonuc.params,
|
params: sonuc.params,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
preloadSihirbazAdimlar,
|
preloadSihirbazAdimlar,
|
||||||
} from "@/components/sihirbaz-adimlar-lazy";
|
} from "@/components/sihirbaz-adimlar-lazy";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { olay, siraKovasi } from "@/lib/analitik";
|
||||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||||
import type { SihirbazFacetleri } from "@/lib/db";
|
import type { SihirbazFacetleri } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
@@ -103,6 +104,11 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
|
|||||||
setSira(value);
|
setSira(value);
|
||||||
setFacetler(veri.facetler);
|
setFacetler(veri.facetler);
|
||||||
setAdim("sihirbaz");
|
setAdim("sihirbaz");
|
||||||
|
olay("sira_girildi", {
|
||||||
|
kaynak: "cta",
|
||||||
|
tur,
|
||||||
|
sira_kovasi: siraKovasi(value),
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setHata("Bir şeyler ters gitti, tekrar dener misin?");
|
setHata("Bir şeyler ters gitti, tekrar dener misin?");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
preloadSihirbazAdimlar,
|
preloadSihirbazAdimlar,
|
||||||
} from "@/components/sihirbaz-adimlar-lazy";
|
} from "@/components/sihirbaz-adimlar-lazy";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { olay, siraKovasi } from "@/lib/analitik";
|
||||||
import {
|
import {
|
||||||
profilDuzenlemeyiAc,
|
profilDuzenlemeyiAc,
|
||||||
useTercihProfili,
|
useTercihProfili,
|
||||||
@@ -80,6 +81,11 @@ export function HeroForm() {
|
|||||||
setSira(value);
|
setSira(value);
|
||||||
setFacetler(veri.facetler);
|
setFacetler(veri.facetler);
|
||||||
setModalAcik(true);
|
setModalAcik(true);
|
||||||
|
olay("sira_girildi", {
|
||||||
|
kaynak: "hero",
|
||||||
|
tur,
|
||||||
|
sira_kovasi: siraKovasi(value),
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setHata("Bir şeyler ters gitti, tekrar dener misin?");
|
setHata("Bir şeyler ters gitti, tekrar dener misin?");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
useTercihProfili,
|
useTercihProfili,
|
||||||
} from "@/components/manuel-liste/profil-kapisi-store";
|
} from "@/components/manuel-liste/profil-kapisi-store";
|
||||||
import { PUAN_TURU_ETIKET } from "@/lib/sihirbaz";
|
import { PUAN_TURU_ETIKET } from "@/lib/sihirbaz";
|
||||||
|
import { olay } from "@/lib/analitik";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
@@ -96,6 +97,11 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
|||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const kaydirmaRef = useRef<HTMLDivElement>(null);
|
const kaydirmaRef = useRef<HTMLDivElement>(null);
|
||||||
const [kenarBlur, setKenarBlur] = useState({ ust: false, alt: false });
|
const [kenarBlur, setKenarBlur] = useState({ ust: false, alt: false });
|
||||||
|
// Kapanışta gönderilecek son arama özeti (bkz. aşağıdaki fetch efekti)
|
||||||
|
const sonAramaRef = useRef<{
|
||||||
|
sorgu_uzunlugu: number;
|
||||||
|
sonuc_sayisi: number;
|
||||||
|
} | null>(null);
|
||||||
const temizSorgu = sorgu.trim();
|
const temizSorgu = sorgu.trim();
|
||||||
const aramaAktif = temizSorgu.length >= 2;
|
const aramaAktif = temizSorgu.length >= 2;
|
||||||
|
|
||||||
@@ -163,7 +169,14 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
|||||||
{ signal: controller.signal },
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error("Arama başarısız");
|
if (!response.ok) throw new Error("Arama başarısız");
|
||||||
setSonuclar((await response.json()) as AramaSonuclari);
|
const veri = (await response.json()) as AramaSonuclari;
|
||||||
|
setSonuclar(veri);
|
||||||
|
// Olay burada değil kapanışta gönderilir; fetch her tuş vuruşunda
|
||||||
|
// (160ms debounce) tetikleniyor, aramayı oturum başına bir kez sayarız.
|
||||||
|
sonAramaRef.current = {
|
||||||
|
sorgu_uzunlugu: temizSorgu.length,
|
||||||
|
sonuc_sayisi: veri.bolumler.length + veri.universiteler.length,
|
||||||
|
};
|
||||||
} catch (hata) {
|
} catch (hata) {
|
||||||
if (!(hata instanceof DOMException && hata.name === "AbortError")) {
|
if (!(hata instanceof DOMException && hata.name === "AbortError")) {
|
||||||
setSonuclar(BOS_SONUCLAR);
|
setSonuclar(BOS_SONUCLAR);
|
||||||
@@ -207,6 +220,15 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Arama oturumu kapandığında tek bir olay — hiç arama yapılmadıysa sessiz
|
||||||
|
useEffect(() => {
|
||||||
|
if (acik) return;
|
||||||
|
const son = sonAramaRef.current;
|
||||||
|
if (!son) return;
|
||||||
|
sonAramaRef.current = null;
|
||||||
|
olay("katalog_arama", son);
|
||||||
|
}, [acik]);
|
||||||
|
|
||||||
function kapat() {
|
function kapat() {
|
||||||
setAcik(false);
|
setAcik(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Check, Plus } from "lucide-react";
|
import { Check, Plus } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Program } from "@/lib/db";
|
import type { Program } from "@/lib/db";
|
||||||
|
import { olay } from "@/lib/analitik";
|
||||||
import {
|
import {
|
||||||
listedeMi,
|
listedeMi,
|
||||||
MANUEL_LISTE_MAX,
|
MANUEL_LISTE_MAX,
|
||||||
@@ -48,6 +49,8 @@ export function ProgramEkleButonu({
|
|||||||
toast.info(`Listen ${MANUEL_LISTE_MAX} tercihe ulaştı.`);
|
toast.info(`Listen ${MANUEL_LISTE_MAX} tercihe ulaştı.`);
|
||||||
} else if (sonuc.zatenVar) {
|
} else if (sonuc.zatenVar) {
|
||||||
toast.info("Bu program zaten seçtiklerinde.");
|
toast.info("Bu program zaten seçtiklerinde.");
|
||||||
|
} else {
|
||||||
|
olay("program_eklendi", { kaynak });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
preloadSihirbazAdimlar,
|
preloadSihirbazAdimlar,
|
||||||
} from "@/components/sihirbaz-adimlar-lazy";
|
} from "@/components/sihirbaz-adimlar-lazy";
|
||||||
import type { SihirbazFacetleri } from "@/lib/db";
|
import type { SihirbazFacetleri } from "@/lib/db";
|
||||||
|
import { olay, siraKovasi } from "@/lib/analitik";
|
||||||
import {
|
import {
|
||||||
tercihProfiliYaz,
|
tercihProfiliYaz,
|
||||||
type SihirbazSecimleri,
|
type SihirbazSecimleri,
|
||||||
@@ -142,6 +143,11 @@ function KapıIcerik({
|
|||||||
setTur(turDeger);
|
setTur(turDeger);
|
||||||
setFacetler(veri.facetler);
|
setFacetler(veri.facetler);
|
||||||
adimaGec("sihirbaz");
|
adimaGec("sihirbaz");
|
||||||
|
olay("sira_girildi", {
|
||||||
|
kaynak: duzenle ? "duzenle" : "kapi",
|
||||||
|
tur: turDeger,
|
||||||
|
sira_kovasi: siraKovasi(siraDeger),
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setHata("Bir şeyler ters gitti, tekrar dener misin?");
|
setHata("Bir şeyler ters gitti, tekrar dener misin?");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
35
src/components/rybbit-identify.tsx
Normal file
35
src/components/rybbit-identify.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Rybbit'e giriş yapan kullanıcıyı bildirir.
|
||||||
|
//
|
||||||
|
// Neden burada: oturum zaten UserNav'da (sunucu bileşeni, Suspense içinde)
|
||||||
|
// çözülüyor. Layout'a authClient.useSession() kullanan global bir bileşen
|
||||||
|
// koymak landing'i statik olmaktan çıkarır ve ziyaret başına gereksiz bir
|
||||||
|
// auth turu attırırdı — bkz. hero-form.tsx'teki aynı gerekçe.
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
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;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!kullaniciId) {
|
||||||
|
kullaniciTemizle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const traits: Record<string, string> = {};
|
||||||
|
if (eposta) traits.email = eposta;
|
||||||
|
if (isim) traits.name = isim;
|
||||||
|
kullaniciTanit(kullaniciId, traits);
|
||||||
|
}, [kullaniciId, eposta, isim]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { TransitionPanel } from "@/components/core/transition-panel";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { IlSecimHaritasi } from "@/components/il-secim-haritasi";
|
import { IlSecimHaritasi } from "@/components/il-secim-haritasi";
|
||||||
import { normalizeIlAdi } from "@/lib/harita";
|
import { normalizeIlAdi } from "@/lib/harita";
|
||||||
|
import { olay } from "@/lib/analitik";
|
||||||
import { trBaslikDuzeni } from "@/lib/slug";
|
import { trBaslikDuzeni } from "@/lib/slug";
|
||||||
import type { SihirbazFacetleri } from "@/lib/db";
|
import type { SihirbazFacetleri } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
@@ -147,6 +148,8 @@ export function SihirbazAdimlar({
|
|||||||
setYon(yeniAdim > adim ? 1 : -1);
|
setYon(yeniAdim > adim ? 1 : -1);
|
||||||
setGecisBasladi(true);
|
setGecisBasladi(true);
|
||||||
setAdim(yeniAdim);
|
setAdim(yeniAdim);
|
||||||
|
// Yalnızca ileri geçişler huni adımı sayılır; geri dönüşler tekrar saymasın
|
||||||
|
if (yeniAdim > adim) olay("sihirbaz_adim", { adim: yeniAdim + 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const panelYuksekligi =
|
const panelYuksekligi =
|
||||||
@@ -377,9 +380,15 @@ export function SihirbazAdimlar({
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
onTamamla({ kategoriler, iller, universiteTipi, oncelikler })
|
olay("sihirbaz_tamamlandi", {
|
||||||
}
|
kategori_sayisi: kategoriler.length,
|
||||||
|
il_sayisi: iller.length,
|
||||||
|
uni_tipi: universiteTipi,
|
||||||
|
oncelik_sayisi: oncelikler.length,
|
||||||
|
});
|
||||||
|
onTamamla({ kategoriler, iller, universiteTipi, oncelikler });
|
||||||
|
}}
|
||||||
disabled={kategoriler.length === 0}
|
disabled={kategoriler.length === 0}
|
||||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -155,6 +155,17 @@ export function SiteFooter() {
|
|||||||
İletişim
|
İletişim
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Rybbit canlı ziyaretçi rozeti — sola yaslı */}
|
||||||
|
<div className="mt-6 flex justify-start">
|
||||||
|
<iframe
|
||||||
|
src="https://rybbit.kolaytercih.com/widget/f2cb042c657e?variant=inline&theme=dark"
|
||||||
|
style={{ border: 0, width: 220, height: 36 }}
|
||||||
|
loading="lazy"
|
||||||
|
title="Canlı ziyaretçi"
|
||||||
|
scrolling="no"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Coins } from "lucide-react";
|
|||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
|
import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
|
||||||
|
import { RybbitIdentify } from "@/components/rybbit-identify";
|
||||||
import { SignOutButton } from "./sign-out-button";
|
import { SignOutButton } from "./sign-out-button";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +28,7 @@ export async function UserNav() {
|
|||||||
if (!u) {
|
if (!u) {
|
||||||
return (
|
return (
|
||||||
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
|
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
|
||||||
|
<RybbitIdentify kullaniciId={null} />
|
||||||
<ListemButonu />
|
<ListemButonu />
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
@@ -40,6 +42,7 @@ export async function UserNav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
|
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
|
||||||
|
<RybbitIdentify kullaniciId={u.id} eposta={u.email} isim={u.name} />
|
||||||
<ListemButonu />
|
<ListemButonu />
|
||||||
<Link
|
<Link
|
||||||
href="/paket"
|
href="/paket"
|
||||||
|
|||||||
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 { magicLink } from "better-auth/plugins";
|
||||||
import { appDb, schema } from "./appdb";
|
import { appDb, schema } from "./appdb";
|
||||||
import { grantCredits, DENEME_KREDISI } from "./credits";
|
import { grantCredits, DENEME_KREDISI } from "./credits";
|
||||||
|
import { magicLinkEpostasi } from "./eposta";
|
||||||
|
|
||||||
async function sendMagicLinkEmail(email: string, url: string) {
|
async function sendMagicLinkEmail(email: string, url: string) {
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (process.env.NODE_ENV !== "production") {
|
||||||
@@ -17,11 +18,15 @@ async function sendMagicLinkEmail(email: string, url: string) {
|
|||||||
}
|
}
|
||||||
const { Resend } = await import("resend");
|
const { Resend } = await import("resend");
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||||
|
const { subject, html, text } = magicLinkEpostasi(url);
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: process.env.EMAIL_FROM ?? "KolayTercih <onboarding@resend.dev>",
|
from: process.env.EMAIL_FROM ?? "KolayTercih <onboarding@resend.dev>",
|
||||||
to: email,
|
to: email,
|
||||||
subject: "KolayTercih giriş bağlantın",
|
subject,
|
||||||
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>`,
|
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 { appDb, schema } from "./appdb";
|
||||||
import { grantCredits, URUNLER } from "./credits";
|
import { grantCredits, URUNLER } from "./credits";
|
||||||
import { retrieveCheckoutForm } from "./iyzico";
|
import { retrieveCheckoutForm } from "./iyzico";
|
||||||
|
import { sunucuOlayi } from "./analitik-sunucu";
|
||||||
|
|
||||||
const { orders } = schema;
|
const { orders } = schema;
|
||||||
|
|
||||||
@@ -57,6 +58,18 @@ export async function odemeyiSonuclandir(
|
|||||||
refId: order.id,
|
refId: order.id,
|
||||||
setHasPaket: order.product === "paket",
|
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";
|
return "paid";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user