Merge branch 'gece/p4-eposta' into gece/entegrasyon-test
This commit is contained in:
@@ -26,11 +26,18 @@ jobs:
|
|||||||
GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}
|
GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||||
RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
|
RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
|
||||||
EMAIL_FROM=${{ secrets.EMAIL_FROM }}
|
EMAIL_FROM=${{ secrets.EMAIL_FROM }}
|
||||||
|
# İsteğe bağlı: secret yoksa boş gelir → kod destek@kolaytercih.com'a düşer.
|
||||||
|
EMAIL_REPLY_TO=${{ secrets.EMAIL_REPLY_TO }}
|
||||||
IYZICO_API_KEY=${{ secrets.IYZICO_API_KEY }}
|
IYZICO_API_KEY=${{ secrets.IYZICO_API_KEY }}
|
||||||
IYZICO_SECRET_KEY=${{ secrets.IYZICO_SECRET_KEY }}
|
IYZICO_SECRET_KEY=${{ secrets.IYZICO_SECRET_KEY }}
|
||||||
IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }}
|
IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }}
|
||||||
OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}
|
OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}
|
||||||
OPENROUTER_MODEL=${{ secrets.OPENROUTER_MODEL }}
|
OPENROUTER_MODEL=${{ secrets.OPENROUTER_MODEL }}
|
||||||
|
# Sunucu tarafı Rybbit olayları (odeme_tamamlandi, giris_basarili —
|
||||||
|
# src/lib/analitik-sunucu.ts). Üçünden biri boşsa gönderim no-op.
|
||||||
|
RYBBIT_HOST=${{ secrets.RYBBIT_HOST }}
|
||||||
|
RYBBIT_SITE_ID=${{ secrets.RYBBIT_SITE_ID }}
|
||||||
|
RYBBIT_API_KEY=${{ secrets.RYBBIT_API_KEY }}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# BuildKit'te "docker build --memory" YOK SAYILIR (moby/buildkit#1362);
|
# BuildKit'te "docker build --memory" YOK SAYILIR (moby/buildkit#1362);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
/**
|
/**
|
||||||
* Rybbit'e sunucudan olay gönderimi (POST /api/track). Yalnızca sunucu
|
* 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.
|
* tarafından çağrılır (lib/odeme.ts, lib/auth.ts) — anahtar NEXT_PUBLIC_ değil.
|
||||||
*
|
*
|
||||||
* Neden sunucu tarafı: ödeme dönüşümü tarayıcıdan gönderilirse reklam
|
* 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.
|
* engelleyiciler ve iyzico dönüşünde kapatılan sekmeler yüzünden kaybolur.
|
||||||
@@ -14,7 +14,7 @@ const HOST = process.env.RYBBIT_HOST;
|
|||||||
const SITE_ID = process.env.RYBBIT_SITE_ID;
|
const SITE_ID = process.env.RYBBIT_SITE_ID;
|
||||||
const API_KEY = process.env.RYBBIT_API_KEY;
|
const API_KEY = process.env.RYBBIT_API_KEY;
|
||||||
|
|
||||||
export type SunucuOlayAdi = "odeme_tamamlandi";
|
export type SunucuOlayAdi = "odeme_tamamlandi" | "giris_basarili";
|
||||||
|
|
||||||
export async function sunucuOlayi(
|
export async function sunucuOlayi(
|
||||||
ad: SunucuOlayAdi,
|
ad: SunucuOlayAdi,
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import "server-only";
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { magicLink } from "better-auth/plugins";
|
import { magicLink } from "better-auth/plugins";
|
||||||
|
import { sunucuOlayi } from "./analitik-sunucu";
|
||||||
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";
|
import { magicLinkEpostasi } from "./eposta";
|
||||||
import { epostaGonder } from "./eposta-gonder";
|
import { epostaGonder, epostaKonsolModu } from "./eposta-gonder";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify URL'ini maile konacak onay sayfası URL'ine çevirir. Doğrulama linki
|
* Verify URL'ini maile konacak onay sayfası URL'ine çevirir. Doğrulama linki
|
||||||
@@ -22,7 +23,67 @@ function onaySayfasiUrl(verifyUrl: string): string {
|
|||||||
return hedef.toString();
|
return hedef.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Aynı adrese bu süre içinde en çok bir giriş e-postası gider. */
|
||||||
|
const GIRIS_SOGUMA_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adres → son gönderim zamanı. Süreç içi Map: tek konteyner çalışıyoruz,
|
||||||
|
* şema/DB yazımı gerektirmez; yeniden başlatmada sıfırlanır (ikinci katman
|
||||||
|
* better-auth'un IP limiti). `verification` tablosundan türetilmez — orada
|
||||||
|
* identifier token'dır ve satır sendMagicLink'ten ÖNCE yazılır. globalThis:
|
||||||
|
* modül birden çok kez yüklense de tek kayıt (appdb/index.ts deseni).
|
||||||
|
*/
|
||||||
|
function girisSogumaKaydi(): Map<string, number> {
|
||||||
|
const g = globalThis as { __girisEpostaSon?: Map<string, number> };
|
||||||
|
return (g.__girisEpostaSon ??= new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
function sogumaAnahtari(email: string): string {
|
||||||
|
return email.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adres soğumada değilse pencereyi başlatır ve `true` döner; soğumadaysa
|
||||||
|
* `false` (pencere kaymaz — süre ilk gönderimden sayılır). Her çağrıda süresi
|
||||||
|
* dolmuş kayıtlar budanır, Map büyümez.
|
||||||
|
*/
|
||||||
|
export function girisSogumasiAl(email: string, simdi = Date.now()): boolean {
|
||||||
|
const kayit = girisSogumaKaydi();
|
||||||
|
for (const [adres, zaman] of kayit) {
|
||||||
|
if (simdi - zaman >= GIRIS_SOGUMA_MS) kayit.delete(adres);
|
||||||
|
}
|
||||||
|
const anahtar = sogumaAnahtari(email);
|
||||||
|
if (kayit.has(anahtar)) return false;
|
||||||
|
kayit.set(anahtar, simdi);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gönderim başarısızsa pencereyi geri verir — kullanıcı hemen yeniden deneyebilsin. */
|
||||||
|
export function girisSogumasiBirak(email: string): void {
|
||||||
|
girisSogumaKaydi().delete(sogumaAnahtari(email));
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMagicLinkEmail(email: string, url: string) {
|
async function sendMagicLinkEmail(email: string, url: string) {
|
||||||
|
// Soğuma yalnızca prod'da: dev paneli "anında giriş" aynı adrese art arda
|
||||||
|
// istek atıyor. Konsol modunda da işler (lokal docker testi görsün).
|
||||||
|
const sogumaAcik = process.env.NODE_ENV === "production";
|
||||||
|
if (sogumaAcik && !girisSogumasiAl(email)) {
|
||||||
|
// Sessizce çık: istek 200 döner, form "gönderildi" gösterir, mail çıkmaz —
|
||||||
|
// üçüncü kişiye mail bombardımanı ve Resend kotası yakılması engellenir.
|
||||||
|
console.info(
|
||||||
|
"[giris] Soğuma penceresinde tekrar istek — e-posta gönderilmedi.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await girisLinkiniIlet(email, url);
|
||||||
|
} catch (err) {
|
||||||
|
if (sogumaAcik) girisSogumasiBirak(email);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function girisLinkiniIlet(email: string, url: string) {
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (process.env.NODE_ENV !== "production") {
|
||||||
// Dev paneli "anında giriş" için son üretilen linki yakalar (bkz.
|
// Dev paneli "anında giriş" için son üretilen linki yakalar (bkz.
|
||||||
// src/lib/dev/actions.ts) — panel onay adımını atlayıp doğrudan verify
|
// src/lib/dev/actions.ts) — panel onay adımını atlayıp doğrudan verify
|
||||||
@@ -30,13 +91,15 @@ async function sendMagicLinkEmail(email: string, url: string) {
|
|||||||
(globalThis as { __sonMagicLink?: string }).__sonMagicLink = url;
|
(globalThis as { __sonMagicLink?: string }).__sonMagicLink = url;
|
||||||
}
|
}
|
||||||
const mailLinki = onaySayfasiUrl(url);
|
const mailLinki = onaySayfasiUrl(url);
|
||||||
if (!process.env.RESEND_API_KEY) {
|
if (epostaKonsolModu()) {
|
||||||
// Dev fallback: Resend anahtarı yoksa linki terminale yaz
|
// Konsol modu (dev ya da EPOSTA_KONSOL_FALLBACK=1): linki terminale yaz.
|
||||||
|
// Prod'da anahtar yoksa buraya girilmez; epostaGonder hata fırlatır —
|
||||||
|
// token'lı link prod loglarına düşmez.
|
||||||
console.log(`\n[giris] Magic link for ${email}:\n${mailLinki}\n`);
|
console.log(`\n[giris] Magic link for ${email}:\n${mailLinki}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Gönderim hatası better-auth'a fırlatılır — aksi halde kullanıcı
|
// Gönderim/yapılandırma hatası better-auth'a fırlatılır — aksi halde
|
||||||
// "gönderildi" ekranını görür ama mail hiç çıkmaz.
|
// kullanıcı "gönderildi" ekranını görür ama mail hiç çıkmaz.
|
||||||
await epostaGonder(email, magicLinkEpostasi(mailLinki));
|
await epostaGonder(email, magicLinkEpostasi(mailLinki));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +158,28 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
session: {
|
||||||
|
create: {
|
||||||
|
after: async (session, ctx) => {
|
||||||
|
// Huninin son adımı: oturum gerçekten açıldı. Sunucudan gider —
|
||||||
|
// reklam engelleyici ve kapanan sekme kaybettirmez. `await` YOK:
|
||||||
|
// Rybbit'in 3 sn'lik timeout'u girişi geciktirmesin (sunucuOlayi
|
||||||
|
// hata fırlatmaz). E-posta adresi özelliklere girmez.
|
||||||
|
const yol = ctx?.path ?? "";
|
||||||
|
void sunucuOlayi("giris_basarili", {
|
||||||
|
kullaniciId: session.userId,
|
||||||
|
pathname: "/giris",
|
||||||
|
ozellikler: {
|
||||||
|
yontem: yol.startsWith("/magic-link")
|
||||||
|
? "eposta"
|
||||||
|
: yol.startsWith("/callback")
|
||||||
|
? "google"
|
||||||
|
: "diger",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,62 @@ import type { EpostaIcerik } from "./eposta";
|
|||||||
/**
|
/**
|
||||||
* Ortak e-posta göndericisi (Resend). Şablonlar eposta.ts'te, gönderim
|
* Ortak e-posta göndericisi (Resend). Şablonlar eposta.ts'te, gönderim
|
||||||
* burada — magic link (auth.ts) ve kredi hatırlatması aynı yoldan çıkar.
|
* burada — magic link (auth.ts) ve kredi hatırlatması aynı yoldan çıkar.
|
||||||
* RESEND_API_KEY yoksa dev fallback: içerik terminale yazılır.
|
*
|
||||||
|
* Env okumalarının tamamı ÇAĞRI ANINDA yapılır, modül seviyesinde değil:
|
||||||
|
* `next build` NODE_ENV=production + env'siz koşar; modül yüklenirken hata
|
||||||
|
* fırlatmak build'i kırar.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Maile yanıt verilirse düşeceği adres — mail.kolaytercih.com'un MX'i yok. */
|
||||||
|
const VARSAYILAN_YANIT_ADRESI = "destek@kolaytercih.com";
|
||||||
|
|
||||||
|
/** Prod'da konsol fallback'ini bilinçli açan kaçış bayrağı (lokal docker testi). */
|
||||||
|
function kacisBayragi(): boolean {
|
||||||
|
return process.env.EPOSTA_KONSOL_FALLBACK === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konsol modu: gönderim yerine içerik terminale yazılır. Yalnızca Resend
|
||||||
|
* anahtarı yokken ve (dev'de ya da EPOSTA_KONSOL_FALLBACK=1 ile) geçerlidir.
|
||||||
|
* Production'da anahtar yoksa konsol moduna DÜŞÜLMEZ — giriş token'ı taşıyan
|
||||||
|
* link prod loglarına yazılmasın, kullanıcı da "gönderildi" sanmasın diye
|
||||||
|
* epostaGonder net hata fırlatır.
|
||||||
|
*/
|
||||||
|
export function epostaKonsolModu(): boolean {
|
||||||
|
return (
|
||||||
|
!process.env.RESEND_API_KEY &&
|
||||||
|
(process.env.NODE_ENV !== "production" || kacisBayragi())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function epostaGonder(
|
export async function epostaGonder(
|
||||||
to: string,
|
to: string,
|
||||||
icerik: EpostaIcerik,
|
icerik: EpostaIcerik,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!process.env.RESEND_API_KEY) {
|
if (epostaKonsolModu()) {
|
||||||
console.log(
|
console.log(
|
||||||
`\n[eposta] (dev) ${to} adresine gönderilecekti — ${icerik.subject}\n${icerik.text}\n`,
|
`\n[eposta] (konsol modu) ${to} adresine gönderilecekti — ${icerik.subject}\n${icerik.text}\n`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const anahtar = process.env.RESEND_API_KEY;
|
||||||
|
if (!anahtar) {
|
||||||
|
throw new Error("[eposta] RESEND_API_KEY tanımlı değil");
|
||||||
|
}
|
||||||
|
// EMAIL_FROM yoksa resend.dev adresi yalnızca Resend hesap sahibine teslim
|
||||||
|
// eder — prod'da sessizce buna düşmek yerine yapılandırma hatasını göster.
|
||||||
|
const gonderen = process.env.EMAIL_FROM;
|
||||||
|
if (!gonderen && process.env.NODE_ENV === "production" && !kacisBayragi()) {
|
||||||
|
throw new Error("[eposta] EMAIL_FROM tanımlı değil");
|
||||||
|
}
|
||||||
const { Resend } = await import("resend");
|
const { Resend } = await import("resend");
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
const resend = new Resend(anahtar);
|
||||||
const { error } = await resend.emails.send({
|
const { error } = await resend.emails.send({
|
||||||
from: process.env.EMAIL_FROM ?? "KolayTercih <onboarding@resend.dev>",
|
from: gonderen || "KolayTercih <onboarding@resend.dev>",
|
||||||
to,
|
to,
|
||||||
|
// `||`: CI'da secret tanımsızsa env boş string gelir; boş Reply-To ile
|
||||||
|
// Resend'e gitmek yerine varsayılana düş.
|
||||||
|
replyTo: process.env.EMAIL_REPLY_TO || VARSAYILAN_YANIT_ADRESI,
|
||||||
subject: icerik.subject,
|
subject: icerik.subject,
|
||||||
html: icerik.html,
|
html: icerik.html,
|
||||||
// Düz metin alternatifi spam puanını düşürür ve HTML'i engelleyen
|
// Düz metin alternatifi spam puanını düşürür ve HTML'i engelleyen
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import "server-only";
|
|||||||
* desteğini güvenilir biçimde sunmaz — bu yüzden düzen tablo tabanlı ve tüm
|
* 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 sitedeki SVG'nin raster kopyası olan
|
* stiller satır içi. Logo işareti sitedeki SVG'nin raster kopyası olan
|
||||||
* `/kolay-tercih-mark.png` ile gösterilir (SVG'yi Gmail/Outlook engeller).
|
* `/kolay-tercih-mark.png` ile gösterilir (SVG'yi Gmail/Outlook engeller).
|
||||||
* Web fontları (Bricolage Grotesque, Cal Sans) <link> ile progresif olarak
|
* Harici font <link>'i yok: çoğu istemci yok sayıyor, bazı spam filtreleri
|
||||||
* yüklenir; desteklemeyen istemciler sistem yazı tipine düşer.
|
* harici kaynağı eksi puan sayıyor. Cal Sans / Bricolage Grotesque yalnızca
|
||||||
|
* cihazda kuruluysa görünür; aksi hâlde sistem yazı tipine düşer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SITE_URL } from "./seo";
|
import { SITE_URL } from "./seo";
|
||||||
@@ -25,6 +26,11 @@ const BASLIK_TIPI = `'Cal Sans', ${YAZI_TIPI}`;
|
|||||||
/** Sitedeki kelime markası Bricolage Grotesque (font-bricolage). */
|
/** Sitedeki kelime markası Bricolage Grotesque (font-bricolage). */
|
||||||
const MARKA_TIPI = `'Bricolage Grotesque', ${YAZI_TIPI}`;
|
const MARKA_TIPI = `'Bricolage Grotesque', ${YAZI_TIPI}`;
|
||||||
|
|
||||||
|
/** Alt bilgideki iletişim satırı — HTML footer ve düz metin gövdeler ortak kullanır. */
|
||||||
|
const SITE_ALANI = "kolaytercih.com";
|
||||||
|
const DESTEK_EPOSTA = "destek@kolaytercih.com";
|
||||||
|
const METIN_ALT_BILGI = ["", "—", `${SITE_ALANI} · ${DESTEK_EPOSTA}`];
|
||||||
|
|
||||||
export type EpostaIcerik = {
|
export type EpostaIcerik = {
|
||||||
subject: string;
|
subject: string;
|
||||||
html: string;
|
html: string;
|
||||||
@@ -76,8 +82,6 @@ function kabuk({
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
<title>${kacir(baslikEtiketi)}</title>
|
<title>${kacir(baslikEtiketi)}</title>
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,300;12..96,600&family=Cal+Sans&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
</head>
|
||||||
<body style="margin:0;padding:0;background-color:${ZEMIN};">
|
<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>
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;font-size:1px;line-height:1px;">${onizleme}</div>
|
||||||
@@ -96,7 +100,8 @@ function kabuk({
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="padding:20px 8px 0;font-family:${YAZI_TIPI};font-size:12px;line-height:1.6;color:#94a3b8;">
|
<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>
|
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.
|
Bu e-postayı hesabınla ilgili bir işlem yaptığın için aldın.<br>
|
||||||
|
<a href="https://${SITE_ALANI}" style="color:#94a3b8;">${SITE_ALANI}</a> · <a href="mailto:${DESTEK_EPOSTA}" style="color:#94a3b8;">${DESTEK_EPOSTA}</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -155,6 +160,7 @@ export function magicLinkEpostasi(url: string): EpostaIcerik {
|
|||||||
"",
|
"",
|
||||||
"Bağlantı 15 dakika geçerli ve yalnızca bir kez kullanılabilir.",
|
"Bağlantı 15 dakika geçerli ve yalnızca bir kez kullanılabilir.",
|
||||||
"Bu isteği sen yapmadıysan bu e-postayı yok sayabilirsin.",
|
"Bu isteği sen yapmadıysan bu e-postayı yok sayabilirsin.",
|
||||||
|
...METIN_ALT_BILGI,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -201,6 +207,7 @@ export function krediHatirlatmaEpostasi(): EpostaIcerik {
|
|||||||
"",
|
"",
|
||||||
"Listenin tamamını açmak ya da danışmana soru sormak istersen kredi",
|
"Listenin tamamını açmak ya da danışmana soru sormak istersen kredi",
|
||||||
"yükleyebilirsin — listen ve sohbet geçmişin olduğu gibi durur.",
|
"yükleyebilirsin — listen ve sohbet geçmişin olduğu gibi durur.",
|
||||||
|
...METIN_ALT_BILGI,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user