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:
Alireza Hassani
2026-09-16 17:33:44 +03:30
co-authored by Cursor
parent 9ce93b0b48
commit 652f4d07b6
11 changed files with 1195 additions and 22 deletions
+397
View File
@@ -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()]
}