From 931036fe62130bd6851174ad3f129e40d77b5ed8 Mon Sep 17 00:00:00 2001 From: Hikmet Date: Sun, 23 Aug 2026 06:12:49 +0300 Subject: [PATCH] fix: match Turkish search accents --- src/app/dashboard/ads/page.tsx | 16 ++++++++-------- src/lib/ad-search.ts | 19 +++++++++++++++++++ tests/core.test.ts | 7 ++++++- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/app/dashboard/ads/page.tsx b/src/app/dashboard/ads/page.tsx index 017a02c..fcd5fac 100644 --- a/src/app/dashboard/ads/page.tsx +++ b/src/app/dashboard/ads/page.tsx @@ -8,7 +8,7 @@ import { Card } from "@/components/ui/card"; import { AdCreativeMedia } from "@/components/ad-creative-media"; import { ApifyEmptySearch } from "@/components/ads/apify-empty-search"; 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> }) { const user = await requireUser(); @@ -26,13 +26,13 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise< const displayableCreativeWhere: Prisma.AdCreativeWhereInput = { url: { not: "" } }; const where: Prisma.AdWhereInput = { creatives: { some: displayableCreativeWhere } }; if (q) { - const terms = matchMode === "EXACT_PHRASE" ? [q] : adSearchTokens(q); - where.AND = terms.map((term) => ({ OR: [ - { primaryText: { contains: term, mode: "insensitive" } }, - { headline: { contains: term, mode: "insensitive" } }, - { description: { contains: term, mode: "insensitive" } }, - { brandPage: { name: { contains: term, mode: "insensitive" } } } - ] })); + const terms = adSearchDatabaseTerms(q, matchMode); + where.AND = terms.map((variants) => ({ OR: variants.flatMap((term) => [ + { primaryText: { contains: term, mode: "insensitive" as const } }, + { headline: { contains: term, mode: "insensitive" as const } }, + { description: { contains: term, mode: "insensitive" as const } }, + { brandPage: { name: { contains: term, mode: "insensitive" as const } } } + ]) })); } if (niche) where.niche = { contains: niche, mode: "insensitive" }; if (mediaType) where.mediaType = mediaType as any; diff --git a/src/lib/ad-search.ts b/src/lib/ad-search.ts index 6a6e7c4..fa7f266 100644 --- a/src/lib/ad-search.ts +++ b/src/lib/ad-search.ts @@ -22,6 +22,25 @@ export function adSearchTokens(query: string) { 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) { return Boolean(text && phrase && ` ${text} `.includes(` ${phrase} `)); } diff --git a/tests/core.test.ts b/tests/core.test.ts index ed549bf..39a8e29 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { MediaType } from "@prisma/client"; 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 { istemciIp, hizSiniriAsimi } from "../src/lib/rate-limit"; 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); }); +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", () => { const ads = [ { headline: null, primaryText: "Use translate today", brandName: "Other" },