Compare commits
2
Commits
e25d70fcd8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
931036fe62 | ||
|
|
705d94037d |
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { currentUser } from "@/lib/auth/current-user";
|
import { currentUser } from "@/lib/auth/current-user";
|
||||||
import { importApifyAds, normalizeApifyAd, relevantApifyRecords, runApifyActor } from "@/lib/apify";
|
import { importApifyAds, runApifyActor, selectMediaRecordsForSearch } from "@/lib/apify";
|
||||||
import { searchMetaAds } from "@/lib/meta-ads";
|
import { searchMetaAds } from "@/lib/meta-ads";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { planFromUser } from "@/lib/plans";
|
import { planFromUser } from "@/lib/plans";
|
||||||
@@ -68,35 +68,40 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const meta = await searchMetaAds({
|
// Meta Ad Library ham medya dosyası vermez. Doğrulanan reklamların görsel/video
|
||||||
|
// dosyaları aynı Meta Ads Library sorgusunun medya katmanından tamamlanır.
|
||||||
|
// İki uzak çağrı paralel çalışır; resmi kimlik eşleşmeleri önceliklidir, ancak
|
||||||
|
// farklı sayfalama nedeniyle kimliği kesişmeyen ilgili medya sonuçları atılmaz.
|
||||||
|
const [meta, mediaRecords] = await Promise.all([
|
||||||
|
searchMetaAds({
|
||||||
searchTerm: parsed.data.searchTerm,
|
searchTerm: parsed.data.searchTerm,
|
||||||
country: parsed.data.country,
|
country: parsed.data.country,
|
||||||
adActiveStatus: parsed.data.status,
|
adActiveStatus: parsed.data.status,
|
||||||
matchMode: parsed.data.matchMode,
|
matchMode: parsed.data.matchMode,
|
||||||
maxResults: parsed.data.maxResults
|
maxResults: parsed.data.maxResults
|
||||||
});
|
}),
|
||||||
// Meta Ad Library ham medya dosyası vermez. Doğrulanan reklamların görsel/video
|
runApifyActor({
|
||||||
// dosyaları mevcut medya katmanından tamamlanır; Meta token'ı istemciye çıkmaz.
|
|
||||||
const candidateLimit = Math.min(parsed.data.maxResults * 3, 300);
|
|
||||||
const mediaRecords = await runApifyActor({
|
|
||||||
searchTerms: [parsed.data.searchTerm],
|
searchTerms: [parsed.data.searchTerm],
|
||||||
country: parsed.data.country,
|
country: parsed.data.country,
|
||||||
adActiveStatus: parsed.data.status,
|
adActiveStatus: parsed.data.status,
|
||||||
mediaType: parsed.data.mediaType,
|
mediaType: parsed.data.mediaType,
|
||||||
maxResults: candidateLimit,
|
maxResults: parsed.data.maxResults,
|
||||||
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
|
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
|
||||||
scrapeAdDetails: true,
|
scrapeAdDetails: true,
|
||||||
includeAboutPage: false
|
includeAboutPage: false
|
||||||
});
|
})
|
||||||
const officialIds = new Set(meta.relevant.map((record) => String(record.adArchiveID || "")));
|
]);
|
||||||
const relevantMediaRecords = relevantApifyRecords(mediaRecords, parsed.data.searchTerm, parsed.data.matchMode, parsed.data.maxResults)
|
const mediaSelection = selectMediaRecordsForSearch(
|
||||||
.filter((record) => {
|
mediaRecords,
|
||||||
const normalized = normalizeApifyAd(record);
|
meta.relevant,
|
||||||
return normalized ? officialIds.has(normalized.externalAdId) : false;
|
parsed.data.searchTerm,
|
||||||
});
|
parsed.data.matchMode,
|
||||||
|
parsed.data.mediaType,
|
||||||
|
parsed.data.maxResults
|
||||||
|
);
|
||||||
const officialResult = await importApifyAds(meta.relevant);
|
const officialResult = await importApifyAds(meta.relevant);
|
||||||
const mediaResult = await importApifyAds(relevantMediaRecords);
|
const mediaResult = await importApifyAds(mediaSelection.records);
|
||||||
const delivered = Math.min(meta.relevant.length, relevantMediaRecords.length);
|
const delivered = Math.min(parsed.data.maxResults, mediaResult.imported);
|
||||||
if (creditsReserved && delivered < parsed.data.maxResults) {
|
if (creditsReserved && delivered < parsed.data.maxResults) {
|
||||||
await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - delivered });
|
await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - delivered });
|
||||||
}
|
}
|
||||||
@@ -107,10 +112,10 @@ export async function POST(request: Request) {
|
|||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
recordsImported: mediaResult.imported,
|
recordsImported: mediaResult.imported,
|
||||||
recordsFailed: officialResult.failed + mediaResult.failed,
|
recordsFailed: officialResult.failed + mediaResult.failed,
|
||||||
metadata: { userId: user.id, provider: "meta", searchTerm: parsed.data.searchTerm, country: parsed.data.country, mediaType: parsed.data.mediaType, matchMode: parsed.data.matchMode, status: parsed.data.status, maxResults: parsed.data.maxResults, received: meta.received, relevant: meta.relevant.length, mediaEnriched: mediaResult.imported }
|
metadata: { userId: user.id, provider: "meta", searchTerm: parsed.data.searchTerm, country: parsed.data.country, mediaType: parsed.data.mediaType, matchMode: parsed.data.matchMode, status: parsed.data.status, maxResults: parsed.data.maxResults, received: meta.received, relevant: meta.relevant.length, mediaCandidates: mediaRecords.length, officialMediaMatches: mediaSelection.officialMatches, fallbackMediaMatches: mediaSelection.fallbackMatches, mediaEnriched: mediaResult.imported }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return NextResponse.json({ ok: true, provider: "meta", imported: mediaResult.imported, failed: officialResult.failed + mediaResult.failed, received: meta.received, relevant: meta.relevant.length, mediaEnriched: mediaResult.imported });
|
return NextResponse.json({ ok: true, provider: "meta", imported: mediaResult.imported, failed: officialResult.failed + mediaResult.failed, received: meta.received, relevant: meta.relevant.length, mediaCandidates: mediaRecords.length, mediaEnriched: mediaResult.imported });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (dailyReserved) await refundQuota({ userId: user.id, metric: "ads_search_daily" });
|
if (dailyReserved) await refundQuota({ userId: user.id, metric: "ads_search_daily" });
|
||||||
if (creditsReserved) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults });
|
if (creditsReserved) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults });
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Card } from "@/components/ui/card";
|
|||||||
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
||||||
import { ApifyEmptySearch } from "@/components/ads/apify-empty-search";
|
import { ApifyEmptySearch } from "@/components/ads/apify-empty-search";
|
||||||
import { AD_COUNTRIES } from "@/lib/countries";
|
import { AD_COUNTRIES } from "@/lib/countries";
|
||||||
import { AdSearchMatchMode, adSearchRelevance, adSearchTokens } from "@/lib/ad-search";
|
import { AdSearchMatchMode, adSearchDatabaseTerms, adSearchRelevance } from "@/lib/ad-search";
|
||||||
|
|
||||||
export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
@@ -26,13 +26,13 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
|
|||||||
const displayableCreativeWhere: Prisma.AdCreativeWhereInput = { url: { not: "" } };
|
const displayableCreativeWhere: Prisma.AdCreativeWhereInput = { url: { not: "" } };
|
||||||
const where: Prisma.AdWhereInput = { creatives: { some: displayableCreativeWhere } };
|
const where: Prisma.AdWhereInput = { creatives: { some: displayableCreativeWhere } };
|
||||||
if (q) {
|
if (q) {
|
||||||
const terms = matchMode === "EXACT_PHRASE" ? [q] : adSearchTokens(q);
|
const terms = adSearchDatabaseTerms(q, matchMode);
|
||||||
where.AND = terms.map((term) => ({ OR: [
|
where.AND = terms.map((variants) => ({ OR: variants.flatMap((term) => [
|
||||||
{ primaryText: { contains: term, mode: "insensitive" } },
|
{ primaryText: { contains: term, mode: "insensitive" as const } },
|
||||||
{ headline: { contains: term, mode: "insensitive" } },
|
{ headline: { contains: term, mode: "insensitive" as const } },
|
||||||
{ description: { contains: term, mode: "insensitive" } },
|
{ description: { contains: term, mode: "insensitive" as const } },
|
||||||
{ brandPage: { name: { contains: term, mode: "insensitive" } } }
|
{ brandPage: { name: { contains: term, mode: "insensitive" as const } } }
|
||||||
] }));
|
]) }));
|
||||||
}
|
}
|
||||||
if (niche) where.niche = { contains: niche, mode: "insensitive" };
|
if (niche) where.niche = { contains: niche, mode: "insensitive" };
|
||||||
if (mediaType) where.mediaType = mediaType as any;
|
if (mediaType) where.mediaType = mediaType as any;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
const RESULT_LIMIT = 10;
|
const RESULT_LIMIT = 10;
|
||||||
@@ -12,6 +12,14 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status,
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const [waitSeconds, setWaitSeconds] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!busy) { setWaitSeconds(0); return; }
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const interval = window.setInterval(() => setWaitSeconds(Math.floor((Date.now() - startedAt) / 1000)), 1000);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [busy]);
|
||||||
|
|
||||||
async function importFromApify() {
|
async function importFromApify() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -33,7 +41,10 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status,
|
|||||||
if (!response.ok) throw new Error("Canlı reklam taraması başarısız oldu.");
|
if (!response.ok) throw new Error("Canlı reklam taraması başarısız oldu.");
|
||||||
|
|
||||||
setError(false);
|
setError(false);
|
||||||
setMessage(`Meta'da ${data.received} aday tarandı, ${data.relevant} ilgili reklam bulundu; ${data.mediaEnriched ?? data.imported ?? 0} reklamın medyası hazırlandı. Sonuçlar yenileniyor…`);
|
const prepared = data.mediaEnriched ?? data.imported ?? 0;
|
||||||
|
setMessage(prepared > 0
|
||||||
|
? `Meta'da ${data.received} aday tarandı, ${data.relevant} ilgili reklam bulundu; ${prepared} reklamın medyası hazırlandı. Sonuçlar yenileniyor…`
|
||||||
|
: `Meta'da ${data.received} aday ve ${data.relevant} ilgili reklam bulundu; seçili ${mediaType === "ALL" ? "medya" : mediaType.toLocaleLowerCase("tr-TR")} türünde kullanılabilir kreatif bulunamadı.`);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(true);
|
setError(true);
|
||||||
@@ -68,10 +79,11 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status,
|
|||||||
onClick={importFromApify}
|
onClick={importFromApify}
|
||||||
className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50"
|
className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busy ? "Getiriliyor…" : planLimit === 0 ? "Planı yükselt" : existingCount > 0 ? "Daha fazla getir" : "Getir"}
|
{busy ? (waitSeconds >= 8 ? "Videolar hazırlanıyor…" : "Meta taranıyor…") : planLimit === 0 ? "Planı yükselt" : existingCount > 0 ? "Daha fazla getir" : "Getir"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-slate-500">Canlı Meta verisi · {country === "ALL" ? "Tüm dünya" : country} · {mediaType === "ALL" ? "Tüm medya" : mediaType} · {matchMode === "EXACT_PHRASE" ? "Tam ifade" : "Tüm kelimeler"} · Plan limiti: {planLimit || "erişim yok"} reklam</p>
|
<p className="mt-2 text-xs text-slate-500">Canlı Meta verisi · {country === "ALL" ? "Tüm dünya" : country} · {mediaType === "ALL" ? "Tüm medya" : mediaType} · {matchMode === "EXACT_PHRASE" ? "Tam ifade" : "Tüm kelimeler"} · Plan limiti: {planLimit || "erişim yok"} reklam</p>
|
||||||
|
{busy && <p className="mt-2 text-xs font-semibold text-violet-700">Meta sonuçları ve gerçek video/görseller hazırlanıyor. Bu işlem genellikle 15–60 saniye sürer.</p>}
|
||||||
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
|
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,25 @@ export function adSearchTokens(query: string) {
|
|||||||
return [...new Set(normalizeAdSearchText(query).split(" ").filter(Boolean))];
|
return [...new Set(normalizeAdSearchText(query).split(" ").filter(Boolean))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preserveAdSearchText(value: string | null | undefined) {
|
||||||
|
return (value || "")
|
||||||
|
.normalize("NFKC")
|
||||||
|
.toLocaleLowerCase("tr-TR")
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adSearchDatabaseTerms(query: string, mode: AdSearchMatchMode) {
|
||||||
|
const preserved = preserveAdSearchText(query);
|
||||||
|
const ascii = normalizeAdSearchText(query);
|
||||||
|
if (mode === "EXACT_PHRASE") return [[...new Set([preserved, ascii].filter(Boolean))]];
|
||||||
|
|
||||||
|
const preservedTokens = preserved.split(" ").filter(Boolean);
|
||||||
|
const asciiTokens = ascii.split(" ").filter(Boolean);
|
||||||
|
return asciiTokens.map((token, index) => [...new Set([preservedTokens[index], token].filter(Boolean))]);
|
||||||
|
}
|
||||||
|
|
||||||
function containsPhrase(text: string, phrase: string) {
|
function containsPhrase(text: string, phrase: string) {
|
||||||
return Boolean(text && phrase && ` ${text} `.includes(` ${phrase} `));
|
return Boolean(text && phrase && ` ${text} `.includes(` ${phrase} `));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,6 +255,39 @@ export function relevantApifyRecords(records: JsonRecord[], query: string, mode:
|
|||||||
return filterRelevantAds(normalized, query, mode, limit).map((item) => item.record);
|
return filterRelevantAds(normalized, query, mode, limit).map((item) => item.record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestedMediaMatches(mediaType: MediaType, requested: ApifyIngestInput["mediaType"]) {
|
||||||
|
if (requested === "ALL" || requested === "NONE") return mediaType !== MediaType.UNKNOWN;
|
||||||
|
if (requested === "VIDEO") return mediaType === MediaType.VIDEO;
|
||||||
|
if (requested === "IMAGE" || requested === "MEME") return mediaType === MediaType.IMAGE;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectMediaRecordsForSearch(
|
||||||
|
records: JsonRecord[],
|
||||||
|
officialRecords: JsonRecord[],
|
||||||
|
query: string,
|
||||||
|
mode: AdSearchMatchMode,
|
||||||
|
mediaType: ApifyIngestInput["mediaType"],
|
||||||
|
limit: number
|
||||||
|
) {
|
||||||
|
const officialIds = new Set(officialRecords.map((record) => String(record.adArchiveID || "")).filter(Boolean));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const candidates = relevantApifyRecords(records, query, mode, Math.max(records.length, limit)).flatMap((record) => {
|
||||||
|
const normalized = normalizeApifyAd(record);
|
||||||
|
if (!normalized?.creativeUrl || !requestedMediaMatches(normalized.mediaType, mediaType) || seen.has(normalized.externalAdId)) return [];
|
||||||
|
seen.add(normalized.externalAdId);
|
||||||
|
return [{ record, externalAdId: normalized.externalAdId }];
|
||||||
|
});
|
||||||
|
const official = candidates.filter((candidate) => officialIds.has(candidate.externalAdId));
|
||||||
|
const fallback = candidates.filter((candidate) => !officialIds.has(candidate.externalAdId));
|
||||||
|
const selected = [...official, ...fallback].slice(0, limit);
|
||||||
|
return {
|
||||||
|
records: selected.map((candidate) => candidate.record),
|
||||||
|
officialMatches: Math.min(official.length, limit),
|
||||||
|
fallbackMatches: Math.max(0, selected.length - Math.min(official.length, limit))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function importApifyAds(records: JsonRecord[]) {
|
export async function importApifyAds(records: JsonRecord[]) {
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|||||||
+19
-2
@@ -1,8 +1,8 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { MediaType } from "@prisma/client";
|
import { MediaType } from "@prisma/client";
|
||||||
import { actorInput, normalizeApifyAd } from "../src/lib/apify";
|
import { actorInput, normalizeApifyAd, selectMediaRecordsForSearch } from "../src/lib/apify";
|
||||||
import { adSearchRelevance, filterRelevantAds } from "../src/lib/ad-search";
|
import { adSearchDatabaseTerms, adSearchRelevance, filterRelevantAds } from "../src/lib/ad-search";
|
||||||
import { getFeatureLimit } from "../src/lib/plans";
|
import { getFeatureLimit } from "../src/lib/plans";
|
||||||
import { istemciIp, hizSiniriAsimi } from "../src/lib/rate-limit";
|
import { istemciIp, hizSiniriAsimi } from "../src/lib/rate-limit";
|
||||||
import { stripePriceFor } from "../src/lib/stripe";
|
import { stripePriceFor } from "../src/lib/stripe";
|
||||||
@@ -155,6 +155,11 @@ test("reklam araması tam kelimeyi eşleştirir ve alakasız alt dizeleri dışa
|
|||||||
assert.equal(adSearchRelevance({ primaryText: "Unrelated summer sale" }, "translate", "ALL_WORDS"), 0);
|
assert.equal(adSearchRelevance({ primaryText: "Unrelated summer sale" }, "translate", "ALL_WORDS"), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("veritabanı araması Türkçe karakterli ve ASCII yazımları birlikte tarar", () => {
|
||||||
|
assert.deepEqual(adSearchDatabaseTerms("çeviri", "ALL_WORDS"), [["çeviri", "ceviri"]]);
|
||||||
|
assert.deepEqual(adSearchDatabaseTerms("hızlı çeviri", "EXACT_PHRASE"), [["hızlı çeviri", "hızlı ceviri"]]);
|
||||||
|
});
|
||||||
|
|
||||||
test("arama sonuçları ilgililiğe göre sıralanıp istenen sayıda kesilir", () => {
|
test("arama sonuçları ilgililiğe göre sıralanıp istenen sayıda kesilir", () => {
|
||||||
const ads = [
|
const ads = [
|
||||||
{ headline: null, primaryText: "Use translate today", brandName: "Other" },
|
{ headline: null, primaryText: "Use translate today", brandName: "Other" },
|
||||||
@@ -165,3 +170,15 @@ test("arama sonuçları ilgililiğe göre sıralanıp istenen sayıda kesilir",
|
|||||||
assert.equal(result.length, 1);
|
assert.equal(result.length, 1);
|
||||||
assert.equal(result[0].headline, "Translate instantly");
|
assert.equal(result[0].headline, "Translate instantly");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Meta medya seçimi resmi kimliği önceler ve ilgili medya yedeğini sıfıra düşürmez", () => {
|
||||||
|
const mediaRecords = [
|
||||||
|
{ ad_id: "fallback-1", ad_body_text: "Anında çeviri yap", ad_format: "video", video_url: "https://video.xx.fbcdn.net/fallback.mp4", page_name: "Çeviri" },
|
||||||
|
{ ad_id: "official-1", ad_body_text: "Çeviri uygulaması", ad_format: "video", video_url: "https://video.xx.fbcdn.net/official.mp4", page_name: "Dil" },
|
||||||
|
{ ad_id: "image-1", ad_body_text: "Çeviri uygulaması", ad_format: "image", image_url: "https://scontent.xx.fbcdn.net/image.jpg", page_name: "Dil" }
|
||||||
|
];
|
||||||
|
const selection = selectMediaRecordsForSearch(mediaRecords, [{ adArchiveID: "official-1" }], "çeviri", "ALL_WORDS", "VIDEO", 2);
|
||||||
|
assert.deepEqual(selection.records.map((record) => record.ad_id), ["official-1", "fallback-1"]);
|
||||||
|
assert.equal(selection.officialMatches, 1);
|
||||||
|
assert.equal(selection.fallbackMatches, 1);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user