Show other-pages stats and store-specials AI matching.
Add the Statistics pages row for static_page views, wire specials AI match UI, and tidy Businesses page styles. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
9ce93b0b48
commit
652f4d07b6
@@ -0,0 +1,397 @@
|
||||
import type { StoreItem } from '../services/storeItemService'
|
||||
import { groupStoreItemsByProduct, type StoreProductListing } from './storeProductGroups'
|
||||
|
||||
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
|
||||
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩'
|
||||
|
||||
const FLUFF_PATTERNS: RegExp[] = [
|
||||
/با\s*گارانتی[\s\S]*$/u,
|
||||
/گارانتی\s*[\d۰-۹٠-٩]+\s*ماهه?/gu,
|
||||
/GSM\s*سرویس/giu,
|
||||
/\(واردات\s*قانونی\)/gu,
|
||||
/واردات\s*قانونی/gu,
|
||||
/دارای\s*نصب\s*شرکتی/gu,
|
||||
/نصب\s*شرکتی/gu,
|
||||
/ساخت\s*[^\s،,]+/gu,
|
||||
/اصلی/gu,
|
||||
/با\s*قیمت[\s\S]*$/u,
|
||||
/قیمت[\s\S]*$/u,
|
||||
]
|
||||
|
||||
const TYPO_FIXES: Array<[RegExp, string]> = [
|
||||
[/لباسویی/gu, 'لباسشویی'],
|
||||
[/الجی/gu, 'LG'],
|
||||
[/بخارشوی/gu, 'بخار شوی'],
|
||||
[/ظرفشویی/gu, 'ظرف شویی'],
|
||||
[/لباسشویی/gu, 'لباس شویی'],
|
||||
]
|
||||
|
||||
/** Persian brand spellings → common Latin catalog titles. */
|
||||
const BRAND_ALIASES: Array<[RegExp, string]> = [
|
||||
[/کارچر/gu, 'Karcher'],
|
||||
[/بوش/gu, 'Bosch'],
|
||||
[/سامسونگ/gu, 'Samsung'],
|
||||
[/ال\s*جی|الجی/gu, 'LG'],
|
||||
[/دوو|دوو\b/gu, 'Daewoo'],
|
||||
[/ایکس\s*ویژن|ایکسویژن/gu, 'X-Vision'],
|
||||
[/شیگلم/gu, 'Sheglam'],
|
||||
[/فیلیپس/gu, 'Philips'],
|
||||
]
|
||||
|
||||
const SEARCH_STOP = new Set([
|
||||
'با',
|
||||
'و',
|
||||
'از',
|
||||
'در',
|
||||
'سایز',
|
||||
'مدل',
|
||||
'کیلو',
|
||||
'اصلی',
|
||||
'سفید',
|
||||
'سیلور',
|
||||
'استیل',
|
||||
'صورتی',
|
||||
'نارنجی',
|
||||
'صدفی',
|
||||
'ساخت',
|
||||
])
|
||||
|
||||
export interface ParsedSpecialLine {
|
||||
index: number
|
||||
raw: string
|
||||
query: string
|
||||
/** @deprecated prefer searchQueries — kept for display/debug */
|
||||
searchQuery: string
|
||||
searchQueries: string[]
|
||||
price: number | null
|
||||
}
|
||||
|
||||
export interface SpecialAiMatchCandidate {
|
||||
storeItemId: string
|
||||
productId: string
|
||||
productTitle: string
|
||||
productNameFa: string
|
||||
productImage: string | null
|
||||
variantId: string
|
||||
variantLabel: string
|
||||
currentPrice: number | null
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface SpecialAiMatchRow {
|
||||
line: ParsedSpecialLine
|
||||
candidate: SpecialAiMatchCandidate | null
|
||||
selected: boolean
|
||||
updatePrice: boolean
|
||||
}
|
||||
|
||||
function toAsciiDigits(value: string): string {
|
||||
return value
|
||||
.split('')
|
||||
.map((ch) => {
|
||||
const p = PERSIAN_DIGITS.indexOf(ch)
|
||||
if (p >= 0) return String(p)
|
||||
const a = ARABIC_DIGITS.indexOf(ch)
|
||||
if (a >= 0) return String(a)
|
||||
return ch
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function normalizeSpaces(value: string): string {
|
||||
return value.replace(/\u200c/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function parseMillionAmount(raw: string): number | null {
|
||||
const match = raw.match(/(\d+(?:[.,]\d+)?)\s*میلیون/u)
|
||||
if (!match) return null
|
||||
const n = Number(match[1].replace(',', '.'))
|
||||
if (!Number.isFinite(n) || n <= 0) return null
|
||||
return Math.round(n * 1_000_000)
|
||||
}
|
||||
|
||||
/** Iranian informal `۸/۲۰۰` → 8,200,000 (millions / thousands). */
|
||||
function parseSlashMillion(raw: string): number | null {
|
||||
const withPrice = raw.match(/(?:قیمت|با\s*قیمت)\s*(\d+)\s*\/\s*(\d{1,3})\b/u)
|
||||
const bareThousands = raw.match(/(?<![\/\d])(\d+)\s*\/\s*(\d{3})\b(?!\s*کیلو)/u)
|
||||
const match = withPrice ?? bareThousands
|
||||
if (!match) return null
|
||||
const millions = Number(match[1])
|
||||
const thousands = Number(match[2])
|
||||
if (!Number.isFinite(millions) || !Number.isFinite(thousands)) return null
|
||||
if (millions <= 0) return null
|
||||
return millions * 1_000_000 + thousands * 1_000
|
||||
}
|
||||
|
||||
/** `قیمت ۶` with a small integer → treat as millions. */
|
||||
function parseBarePriceMillion(raw: string): number | null {
|
||||
const match = raw.match(/(?:قیمت|با\s*قیمت)\s*(\d{1,4})\b(?!\s*[\/میلیون])/u)
|
||||
if (!match) return null
|
||||
const n = Number(match[1])
|
||||
if (!Number.isFinite(n) || n <= 0) return null
|
||||
if (n >= 1000) return n
|
||||
return n * 1_000_000
|
||||
}
|
||||
|
||||
/** Full digit price near the end, e.g. `قیمت 8200000`. */
|
||||
function parseFullDigitPrice(raw: string): number | null {
|
||||
const match = raw.match(/(?:قیمت|با\s*قیمت)\s*(\d{5,})\b/u)
|
||||
if (!match) return null
|
||||
const n = Number(match[1])
|
||||
return Number.isFinite(n) && n > 0 ? n : null
|
||||
}
|
||||
|
||||
export function extractPriceFromLine(line: string): number | null {
|
||||
const ascii = toAsciiDigits(normalizeSpaces(line))
|
||||
return (
|
||||
parseMillionAmount(ascii) ??
|
||||
parseSlashMillion(ascii) ??
|
||||
parseFullDigitPrice(ascii) ??
|
||||
parseBarePriceMillion(ascii)
|
||||
)
|
||||
}
|
||||
|
||||
function buildQuery(line: string): string {
|
||||
let text = toAsciiDigits(normalizeSpaces(line))
|
||||
|
||||
text = text.replace(/\d+(?:[.,]\d+)?\s*میلیون/gu, ' ')
|
||||
text = text.replace(/(?:با\s*)?قیمت\s*\d+(?:\s*\/\s*\d+)?/gu, ' ')
|
||||
|
||||
for (const pattern of FLUFF_PATTERNS) {
|
||||
text = text.replace(pattern, ' ')
|
||||
}
|
||||
|
||||
for (const [from, to] of TYPO_FIXES) {
|
||||
text = text.replace(from, to)
|
||||
}
|
||||
|
||||
// Split Persian letters from digits; keep Latin model codes (Y1, V5, X267).
|
||||
text = text
|
||||
.replace(/(\d)([\u0600-\u06FF])/gu, '$1 $2')
|
||||
.replace(/([\u0600-\u06FF])(\d)/gu, '$1 $2')
|
||||
.replace(/(\d)([A-Za-z])/g, '$1 $2')
|
||||
.replace(/([A-Za-z])([\u0600-\u06FF])/gu, '$1 $2')
|
||||
.replace(/([\u0600-\u06FF])([A-Za-z])/gu, '$1 $2')
|
||||
|
||||
return normalizeSpaces(text)
|
||||
}
|
||||
|
||||
function queryTokens(query: string): string[] {
|
||||
return query
|
||||
.split(' ')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => {
|
||||
if (!t || SEARCH_STOP.has(t.toLowerCase())) return false
|
||||
if (/[A-Za-z]/.test(t) && /\d/.test(t)) return true
|
||||
if (/^\d+(?:\/\d+)?$/.test(t)) return true
|
||||
return t.length >= 2
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* API `name` filter is a single substring `contains` — full lines almost never match.
|
||||
* Return strong short tokens to search separately (model code first).
|
||||
*/
|
||||
export function buildSearchQueries(query: string): string[] {
|
||||
const tokens = queryTokens(query)
|
||||
const modelCodes = tokens.filter((t) => /[A-Za-z]/.test(t) && /\d/.test(t))
|
||||
const latin = tokens.filter((t) => /^[A-Za-z0-9][A-Za-z0-9\-/]*$/.test(t) && t.length >= 2)
|
||||
const persian = tokens.filter((t) => /[\u0600-\u06FF]/.test(t) && t.length >= 3)
|
||||
|
||||
const ordered: string[] = []
|
||||
const push = (value: string) => {
|
||||
const v = value.trim()
|
||||
if (!v || ordered.includes(v)) return
|
||||
ordered.push(v)
|
||||
}
|
||||
|
||||
for (const t of modelCodes) push(t)
|
||||
for (const t of latin) push(t)
|
||||
for (const t of persian) push(t)
|
||||
|
||||
// Brand alias Latin forms also help English titles (Karcher, LG, …)
|
||||
for (const [pattern, latinBrand] of BRAND_ALIASES) {
|
||||
if (pattern.test(query)) push(latinBrand)
|
||||
pattern.lastIndex = 0
|
||||
}
|
||||
|
||||
return ordered.slice(0, 5)
|
||||
}
|
||||
|
||||
/** @deprecated use buildSearchQueries */
|
||||
function buildSearchQuery(query: string): string {
|
||||
return buildSearchQueries(query)[0] ?? query.slice(0, 40)
|
||||
}
|
||||
|
||||
export function parseSpecialItemsList(text: string): ParsedSpecialLine[] {
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => normalizeSpaces(line))
|
||||
.filter(Boolean)
|
||||
|
||||
return lines.map((raw, index) => {
|
||||
const query = buildQuery(raw)
|
||||
const searchQueries = buildSearchQueries(query)
|
||||
return {
|
||||
index,
|
||||
raw,
|
||||
query,
|
||||
searchQuery: searchQueries[0] ?? query.slice(0, 40),
|
||||
searchQueries,
|
||||
price: extractPriceFromLine(raw),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeForScore(value: string): string {
|
||||
let text = toAsciiDigits(normalizeSpaces(value)).toLowerCase()
|
||||
for (const [from, to] of TYPO_FIXES) {
|
||||
text = text.replace(from, to.toLowerCase())
|
||||
}
|
||||
for (const [from, to] of BRAND_ALIASES) {
|
||||
text = text.replace(from, to.toLowerCase())
|
||||
from.lastIndex = 0
|
||||
}
|
||||
return text
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Space-insensitive form so «بخارشوی» ≈ «بخار شوی». */
|
||||
function compactForScore(value: string): string {
|
||||
return normalizeForScore(value).replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function tokenSet(value: string): Set<string> {
|
||||
return new Set(
|
||||
normalizeForScore(value)
|
||||
.split(' ')
|
||||
.filter((t) => t.length >= 2),
|
||||
)
|
||||
}
|
||||
|
||||
export function scoreTextMatch(query: string, haystack: string): number {
|
||||
const q = normalizeForScore(query)
|
||||
const h = normalizeForScore(haystack)
|
||||
if (!q || !h) return 0
|
||||
if (q === h) return 1
|
||||
if (h.includes(q) || q.includes(h)) return 0.92
|
||||
|
||||
const qc = compactForScore(query)
|
||||
const hc = compactForScore(haystack)
|
||||
if (qc && hc && (hc.includes(qc) || qc.includes(hc))) return 0.88
|
||||
|
||||
const qTokens = tokenSet(q)
|
||||
const hTokens = tokenSet(h)
|
||||
if (qTokens.size === 0 || hTokens.size === 0) return 0
|
||||
|
||||
const hCompact = compactForScore(haystack)
|
||||
|
||||
let overlap = 0
|
||||
for (const token of qTokens) {
|
||||
if (hTokens.has(token)) {
|
||||
overlap += 1
|
||||
continue
|
||||
}
|
||||
if (hCompact.includes(token.replace(/\s+/g, ''))) {
|
||||
overlap += 0.85
|
||||
continue
|
||||
}
|
||||
for (const ht of hTokens) {
|
||||
if (ht.includes(token) || token.includes(ht)) {
|
||||
overlap += 0.6
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const coverage = overlap / qTokens.size
|
||||
return Math.min(1, coverage)
|
||||
}
|
||||
|
||||
function listingHaystack(listing: StoreProductListing, variant?: StoreItem): string {
|
||||
const parts = [
|
||||
listing.productTitle,
|
||||
listing.productNameFa,
|
||||
variant?.label ?? '',
|
||||
...(variant?.selections.map((s) => s.value) ?? []),
|
||||
]
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function pickBestVariant(
|
||||
listing: StoreProductListing,
|
||||
query: string,
|
||||
): { variant: StoreItem; score: number } | null {
|
||||
if (listing.variants.length === 0) return null
|
||||
|
||||
let best: { variant: StoreItem; score: number } | null = null
|
||||
for (const variant of listing.variants) {
|
||||
const score = scoreTextMatch(query, listingHaystack(listing, variant))
|
||||
if (!best || score > best.score) {
|
||||
best = { variant, score }
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export function pickBestMatch(
|
||||
line: ParsedSpecialLine,
|
||||
items: StoreItem[],
|
||||
minScore = 0.28,
|
||||
): SpecialAiMatchCandidate | null {
|
||||
const listings = groupStoreItemsByProduct(items)
|
||||
let best: SpecialAiMatchCandidate | null = null
|
||||
|
||||
for (const listing of listings) {
|
||||
const picked = pickBestVariant(listing, line.query)
|
||||
if (!picked) continue
|
||||
|
||||
const storeItemId =
|
||||
listing.representative.storeItemId ?? listing.representative.id
|
||||
const candidate: SpecialAiMatchCandidate = {
|
||||
storeItemId,
|
||||
productId: listing.productId,
|
||||
productTitle: listing.productTitle,
|
||||
productNameFa: listing.productNameFa,
|
||||
productImage: listing.productImage,
|
||||
variantId: picked.variant.id,
|
||||
variantLabel: picked.variant.label,
|
||||
currentPrice: picked.variant.price,
|
||||
score: picked.score,
|
||||
}
|
||||
|
||||
if (!best || candidate.score > best.score) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
|
||||
if (!best || best.score < minScore) return null
|
||||
return best
|
||||
}
|
||||
|
||||
export function buildInitialMatchRows(
|
||||
lines: ParsedSpecialLine[],
|
||||
resultsByLine: Array<StoreItem[]>,
|
||||
): SpecialAiMatchRow[] {
|
||||
return lines.map((line, i) => {
|
||||
const candidate = pickBestMatch(line, resultsByLine[i] ?? [])
|
||||
return {
|
||||
line,
|
||||
candidate,
|
||||
selected: Boolean(candidate),
|
||||
updatePrice: Boolean(candidate && line.price != null),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function mergeStoreItemsById(batches: StoreItem[][]): StoreItem[] {
|
||||
const byId = new Map<string, StoreItem>()
|
||||
for (const batch of batches) {
|
||||
for (const item of batch) {
|
||||
byId.set(item.id, item)
|
||||
}
|
||||
}
|
||||
return [...byId.values()]
|
||||
}
|
||||
Reference in New Issue
Block a user