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,238 @@
|
||||
.modal {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.headerBlock {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.listArea {
|
||||
min-height: 180px;
|
||||
resize: vertical;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--field-font-size);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.checkRow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-ui);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkRow input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reviewHint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.reviewList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: min(48vh, 420px);
|
||||
overflow-y: auto;
|
||||
padding-inline-end: 2px;
|
||||
}
|
||||
|
||||
.reviewCard {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: rgba(15, 23, 42, 0.22);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.reviewCardMiss {
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
background: rgba(120, 53, 15, 0.2);
|
||||
}
|
||||
|
||||
.reviewTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lineIndex {
|
||||
font-weight: 600;
|
||||
color: rgba(226, 232, 240, 0.78);
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #67e8f9;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rawLine {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: rgba(226, 232, 240, 0.72);
|
||||
font-family: var(--font-ui);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.matchBlock {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thumb,
|
||||
.thumbPlaceholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.matchText {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.matchTitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #f8fafc;
|
||||
font-family: var(--font-ui);
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.matchMeta {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: rgba(191, 219, 254, 0.72);
|
||||
font-family: var(--font-ui);
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.priceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.priceLabel {
|
||||
color: rgba(148, 163, 184, 0.92);
|
||||
}
|
||||
|
||||
.priceCurrent {
|
||||
color: rgba(226, 232, 240, 0.72);
|
||||
}
|
||||
|
||||
.priceArrow {
|
||||
color: rgba(103, 232, 249, 0.88);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.priceNext {
|
||||
color: #67e8f9;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.noMatch {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
color: #fecaca;
|
||||
background: rgba(127, 29, 29, 0.35);
|
||||
border: 1px solid rgba(248, 113, 113, 0.28);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
height: var(--field-height);
|
||||
padding: 0 18px;
|
||||
font-size: var(--field-font-size);
|
||||
font-weight: 500;
|
||||
font-family: var(--font-ui);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s, color 0.2s, opacity 0.2s;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.cancelBtn:hover:not(:disabled) {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.cancelBtn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Sparkles, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { updateStoreItem } from '../services/storeItemService'
|
||||
import {
|
||||
matchStoreSpecialListWithAi,
|
||||
updateStoreSpecial,
|
||||
type SpecialItemsAiMatchRow,
|
||||
} from '../services/storeSpecialService'
|
||||
import type { SpecialProductsSource } from '../services/settingsService'
|
||||
import type { StoreSpecial } from '../types/storeSpecial'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
import modalStyles from './VariationsModal.module.css'
|
||||
import aiStyles from '../styles/ai.module.css'
|
||||
import styles from './SpecialItemsAiModal.module.css'
|
||||
|
||||
type Step = 'input' | 'review'
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
|
||||
export interface SpecialItemsAiApplyResult {
|
||||
special: StoreSpecial
|
||||
added: number
|
||||
priced: number
|
||||
cleared: boolean
|
||||
}
|
||||
|
||||
interface SpecialItemsAiModalProps {
|
||||
open: boolean
|
||||
special: StoreSpecial | null
|
||||
source: SpecialProductsSource
|
||||
onClose: () => void
|
||||
onApplied: (result: SpecialItemsAiApplyResult) => void
|
||||
}
|
||||
|
||||
export function SpecialItemsAiModal({
|
||||
open,
|
||||
special,
|
||||
source,
|
||||
onClose,
|
||||
onApplied,
|
||||
}: SpecialItemsAiModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const fromProducts = source === 'product'
|
||||
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [step, setStep] = useState<Step>('input')
|
||||
const [listText, setListText] = useState('')
|
||||
const [replaceExisting, setReplaceExisting] = useState(false)
|
||||
const [rows, setRows] = useState<SpecialItemsAiMatchRow[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
setStep('input')
|
||||
setListText('')
|
||||
setReplaceExisting(false)
|
||||
setRows([])
|
||||
setError('')
|
||||
setBusy(false)
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
const timer = setTimeout(() => {
|
||||
setMounted(false)
|
||||
setClosing(false)
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, mounted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !busy) onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [mounted, closing, busy, onClose])
|
||||
|
||||
const selectedCount = useMemo(
|
||||
() => rows.filter((row) => row.selected && row.candidate).length,
|
||||
[rows],
|
||||
)
|
||||
|
||||
async function handleMatch() {
|
||||
if (!special) return
|
||||
if (listText.trim().length < 3) {
|
||||
setError(t('website.specialItems.ai.listEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await matchStoreSpecialListWithAi(listText)
|
||||
setRows(result.matches)
|
||||
setStep('review')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('website.specialItems.ai.errorMatch'))
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
if (!special) return
|
||||
const accepted = rows.filter((row) => row.selected && row.candidate)
|
||||
if (accepted.length === 0) {
|
||||
setError(t('website.specialItems.ai.noneSelected'))
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
let priced = 0
|
||||
|
||||
if (!fromProducts) {
|
||||
for (const row of accepted) {
|
||||
if (!row.updatePrice || row.price == null || !row.candidate) continue
|
||||
await updateStoreItem(row.candidate.variantId, { price: row.price })
|
||||
priced += 1
|
||||
}
|
||||
}
|
||||
|
||||
const existingIds = special.items.map((item) =>
|
||||
fromProducts ? item.productId : item.id,
|
||||
)
|
||||
const matchedIds = accepted.map((row) =>
|
||||
fromProducts ? row.candidate!.productId : row.candidate!.storeItemId,
|
||||
)
|
||||
const nextIds = replaceExisting
|
||||
? [...new Set(matchedIds)]
|
||||
: [...new Set([...existingIds, ...matchedIds])]
|
||||
|
||||
const result = await updateStoreSpecial(
|
||||
special.id,
|
||||
fromProducts ? { productIds: nextIds } : { storeItemIds: nextIds },
|
||||
)
|
||||
|
||||
onApplied({
|
||||
special: result.special,
|
||||
added: matchedIds.length,
|
||||
priced,
|
||||
cleared: replaceExisting,
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('website.specialItems.ai.errorApply'))
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRow(index: number) {
|
||||
setRows((prev) =>
|
||||
prev.map((row, i) =>
|
||||
i === index && row.candidate ? { ...row, selected: !row.selected } : row,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function togglePrice(index: number) {
|
||||
setRows((prev) =>
|
||||
prev.map((row, i) => (i === index ? { ...row, updatePrice: !row.updatePrice } : row)),
|
||||
)
|
||||
}
|
||||
|
||||
if (!mounted || !special) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={(e) => e.target === e.currentTarget && !busy && onClose()}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="special-items-ai-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={`${modalStyles.header} ${styles.header}`}>
|
||||
<div className={styles.headerBlock}>
|
||||
<h2 id="special-items-ai-title" className={modalStyles.title}>
|
||||
{t('website.specialItems.ai.title')}
|
||||
</h2>
|
||||
<p className={styles.subtitle}>
|
||||
{t('website.specialItems.ai.subtitle', { title: special.title })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`${modalStyles.body} ${styles.body}`}>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
{step === 'input' ? (
|
||||
<>
|
||||
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
|
||||
<label htmlFor="special-items-ai-list">
|
||||
{t('website.specialItems.ai.listLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
id="special-items-ai-list"
|
||||
className={`${aiStyles.aiPromptTextarea} ${styles.listArea}`}
|
||||
value={listText}
|
||||
onChange={(e) => setListText(e.target.value)}
|
||||
rows={10}
|
||||
disabled={busy}
|
||||
placeholder={t('website.specialItems.ai.listPlaceholder')}
|
||||
lang="fa"
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className={styles.checkRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={replaceExisting}
|
||||
onChange={(e) => setReplaceExisting(e.target.checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<span>{t('website.specialItems.ai.replaceExisting')}</span>
|
||||
</label>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelBtn}
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('products.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={aiStyles.aiBtn}
|
||||
onClick={() => void handleMatch()}
|
||||
disabled={busy || listText.trim().length < 3}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
{busy
|
||||
? t('website.specialItems.ai.matching')
|
||||
: t('website.specialItems.ai.match')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className={styles.reviewHint}>
|
||||
{t('website.specialItems.ai.reviewHint', {
|
||||
matched: String(selectedCount),
|
||||
total: String(rows.length),
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className={styles.reviewList}>
|
||||
{rows.map((row, index) => (
|
||||
<div
|
||||
key={`${row.lineIndex}-${row.raw.slice(0, 24)}`}
|
||||
className={`${styles.reviewCard} ${
|
||||
row.candidate ? '' : styles.reviewCardMiss
|
||||
}`}
|
||||
>
|
||||
<div className={styles.reviewTop}>
|
||||
<label className={styles.checkRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.selected}
|
||||
disabled={!row.candidate || busy}
|
||||
onChange={() => toggleRow(index)}
|
||||
/>
|
||||
<span className={styles.lineIndex}>#{index + 1}</span>
|
||||
</label>
|
||||
{row.candidate && (
|
||||
<span className={styles.score}>
|
||||
{Math.round((row.confidence || row.candidate.score) * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={styles.rawLine} dir="rtl" lang="fa">
|
||||
{row.raw}
|
||||
</p>
|
||||
|
||||
{row.candidate ? (
|
||||
<div className={styles.matchBlock}>
|
||||
{row.candidate.productImage ? (
|
||||
<img
|
||||
src={row.candidate.productImage}
|
||||
alt=""
|
||||
className={styles.thumb}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.thumbPlaceholder} />
|
||||
)}
|
||||
<div className={styles.matchText}>
|
||||
<p className={styles.matchTitle}>
|
||||
{isFa && row.candidate.productNameFa
|
||||
? row.candidate.productNameFa
|
||||
: row.candidate.productTitle}
|
||||
</p>
|
||||
{row.candidate.variantLabel && (
|
||||
<p className={styles.matchMeta}>{row.candidate.variantLabel}</p>
|
||||
)}
|
||||
<div className={styles.priceRow}>
|
||||
<span className={styles.priceLabel}>
|
||||
{t('website.specialItems.ai.currentPrice')}
|
||||
</span>
|
||||
<span className={styles.priceCurrent}>
|
||||
{formatIrtPrice(row.candidate.currentPrice)}
|
||||
</span>
|
||||
{row.price != null && (
|
||||
<>
|
||||
<span className={styles.priceArrow}>→</span>
|
||||
<strong className={styles.priceNext}>
|
||||
{formatIrtPrice(row.price)}
|
||||
</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className={styles.noMatch}>{t('website.specialItems.ai.noMatch')}</p>
|
||||
)}
|
||||
|
||||
{row.candidate && row.price != null && !fromProducts && (
|
||||
<label className={styles.checkRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.updatePrice}
|
||||
disabled={!row.selected || busy}
|
||||
onChange={() => togglePrice(index)}
|
||||
/>
|
||||
<span>{t('website.specialItems.ai.updatePrice')}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelBtn}
|
||||
onClick={() => {
|
||||
setStep('input')
|
||||
setError('')
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('website.specialItems.ai.back')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={aiStyles.aiBtn}
|
||||
onClick={() => void handleApply()}
|
||||
disabled={busy || selectedCount === 0}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
{busy
|
||||
? t('website.specialItems.ai.applying')
|
||||
: t('website.specialItems.ai.apply', { count: selectedCount })}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronLeft, ChevronRight, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Pencil, Plus, Sparkles, Trash2 } from 'lucide-react'
|
||||
import { useRef } from 'react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
@@ -15,6 +15,7 @@ interface StoreSpecialCarouselProps {
|
||||
onAddItems: (special: StoreSpecial) => void
|
||||
onEditSpecial: (special: StoreSpecial) => void
|
||||
onDeleteSpecial: (special: StoreSpecial) => void
|
||||
onAiAssist: (special: StoreSpecial) => void
|
||||
onOpenListing: (listing: StoreProductListing) => void
|
||||
onEditListing: (listing: StoreProductListing) => void
|
||||
onDiscountListing: (listing: StoreProductListing) => void
|
||||
@@ -28,6 +29,7 @@ export function StoreSpecialCarousel({
|
||||
onAddItems,
|
||||
onEditSpecial,
|
||||
onDeleteSpecial,
|
||||
onAiAssist,
|
||||
onOpenListing,
|
||||
onEditListing,
|
||||
onDiscountListing,
|
||||
@@ -94,6 +96,16 @@ export function StoreSpecialCarousel({
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<Tooltip label={t('website.specialItems.ai.tooltip')}>
|
||||
<button
|
||||
type="button"
|
||||
className={controlStyles.controlBtn}
|
||||
onClick={() => onAiAssist(special)}
|
||||
aria-label={t('website.specialItems.ai.tooltip')}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('website.specialItems.editCategory')}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -302,6 +302,7 @@ const en = {
|
||||
'statistics.errorLoad': 'Unable to load visit statistics.',
|
||||
'statistics.row.all': 'Total visit',
|
||||
'statistics.row.home': 'Home page',
|
||||
'statistics.row.pages': 'Other pages',
|
||||
'statistics.row.products': 'Products',
|
||||
'statistics.row.blog': 'Blog',
|
||||
'statistics.row.portfolio': 'Portfolio',
|
||||
@@ -1994,6 +1995,37 @@ const en = {
|
||||
'website.specialItems.pickSearchProducts': 'Type 3+ characters to search products',
|
||||
'website.specialItems.pickErrorLoad': 'Unable to load store items.',
|
||||
'website.specialItems.pickErrorLoadProducts': 'Unable to load products.',
|
||||
'website.specialItems.ai.tooltip': 'AI fill / clear row',
|
||||
'website.specialItems.ai.title': 'Special Items AI',
|
||||
'website.specialItems.ai.subtitle': 'Fill “{title}” from a pasted product list.',
|
||||
'website.specialItems.ai.fillTab': 'Fill from list',
|
||||
'website.specialItems.ai.clearTab': 'Clear row',
|
||||
'website.specialItems.ai.listLabel': 'Product list',
|
||||
'website.specialItems.ai.listPlaceholder':
|
||||
'Paste one product per line with price, e.g.\nساید الجی X267 استیل ۶۰۵ میلیون…',
|
||||
'website.specialItems.ai.listEmpty': 'Paste at least one product line.',
|
||||
'website.specialItems.ai.replaceExisting': 'Replace current items in this row',
|
||||
'website.specialItems.ai.match': 'Match products',
|
||||
'website.specialItems.ai.matching': 'Matching…',
|
||||
'website.specialItems.ai.errorMatch': 'Unable to match products from this list.',
|
||||
'website.specialItems.ai.clearHint':
|
||||
'Remove every product from “{title}”. Catalog prices stay unchanged.',
|
||||
'website.specialItems.ai.clearConfirm': 'Clear this row',
|
||||
'website.specialItems.ai.clearing': 'Clearing…',
|
||||
'website.specialItems.ai.errorClear': 'Unable to clear this row.',
|
||||
'website.specialItems.ai.reviewHint': 'Matched {matched} of {total}. Uncheck anything wrong before applying.',
|
||||
'website.specialItems.ai.noMatch': 'No close match found',
|
||||
'website.specialItems.ai.currentPrice': 'Current',
|
||||
'website.specialItems.ai.updatePrice': 'Update store price to list price',
|
||||
'website.specialItems.ai.back': 'Back',
|
||||
'website.specialItems.ai.noneSelected': 'Select at least one matched product.',
|
||||
'website.specialItems.ai.apply': 'Apply {count}',
|
||||
'website.specialItems.ai.applying': 'Applying…',
|
||||
'website.specialItems.ai.errorApply': 'Unable to apply matched products.',
|
||||
'website.specialItems.ai.toastCleared': 'Row cleared.',
|
||||
'website.specialItems.ai.toastApplied': 'Added {count} product(s) to the row.',
|
||||
'website.specialItems.ai.toastAppliedWithPrice':
|
||||
'Added {count} product(s) and updated {priced} price(s).',
|
||||
|
||||
'website.settings.title': 'Website settings',
|
||||
'website.settings.subtitle':
|
||||
@@ -2812,6 +2844,7 @@ const fa: Record<MessageKey, string> = {
|
||||
'statistics.errorLoad': 'بارگذاری آمار بازدید ممکن نشد.',
|
||||
'statistics.row.all': 'بازدید کل',
|
||||
'statistics.row.home': 'صفحه اصلی',
|
||||
'statistics.row.pages': 'سایر صفحات',
|
||||
'statistics.row.products': 'محصولات',
|
||||
'statistics.row.blog': 'بلاگ',
|
||||
'statistics.row.portfolio': 'نمونه کار',
|
||||
@@ -4501,6 +4534,38 @@ const fa: Record<MessageKey, string> = {
|
||||
'website.specialItems.pickSearchProducts': 'حداقل ۳ حرف برای جستجوی محصولات تایپ کنید',
|
||||
'website.specialItems.pickErrorLoad': 'بارگذاری محصولات فروشگاه ممکن نشد.',
|
||||
'website.specialItems.pickErrorLoadProducts': 'بارگذاری محصولات ممکن نشد.',
|
||||
'website.specialItems.ai.tooltip': 'پر کردن / خالی کردن ردیف با هوش مصنوعی',
|
||||
'website.specialItems.ai.title': 'هوش مصنوعی محصولات ویژه',
|
||||
'website.specialItems.ai.subtitle': 'ردیف «{title}» را با لیست متنی پر کنید.',
|
||||
'website.specialItems.ai.fillTab': 'پر کردن از لیست',
|
||||
'website.specialItems.ai.clearTab': 'خالی کردن ردیف',
|
||||
'website.specialItems.ai.listLabel': 'لیست محصولات',
|
||||
'website.specialItems.ai.listPlaceholder':
|
||||
'هر خط یک محصول با قیمت، مثلاً\nساید الجی X267 استیل ۶۰۵ میلیون…',
|
||||
'website.specialItems.ai.listEmpty': 'حداقل یک خط محصول وارد کنید.',
|
||||
'website.specialItems.ai.replaceExisting': 'جایگزینی آیتمهای فعلی این ردیف',
|
||||
'website.specialItems.ai.match': 'یافتن محصولات',
|
||||
'website.specialItems.ai.matching': 'در حال تطبیق…',
|
||||
'website.specialItems.ai.errorMatch': 'تطبیق محصولات از این لیست ممکن نشد.',
|
||||
'website.specialItems.ai.clearHint':
|
||||
'همه محصولات از «{title}» حذف میشوند. قیمتهای کاتالوگ تغییر نمیکنند.',
|
||||
'website.specialItems.ai.clearConfirm': 'خالی کردن این ردیف',
|
||||
'website.specialItems.ai.clearing': 'در حال خالی کردن…',
|
||||
'website.specialItems.ai.errorClear': 'خالی کردن این ردیف ممکن نشد.',
|
||||
'website.specialItems.ai.reviewHint':
|
||||
'{matched} از {total} تطبیق داده شد. قبل از اعمال، موارد اشتباه را بردارید.',
|
||||
'website.specialItems.ai.noMatch': 'تطبیق نزدیکی پیدا نشد',
|
||||
'website.specialItems.ai.currentPrice': 'قیمت فعلی',
|
||||
'website.specialItems.ai.updatePrice': 'بهروزرسانی قیمت فروشگاه با قیمت لیست',
|
||||
'website.specialItems.ai.back': 'بازگشت',
|
||||
'website.specialItems.ai.noneSelected': 'حداقل یک محصول تطبیقدادهشده را انتخاب کنید.',
|
||||
'website.specialItems.ai.apply': 'اعمال {count}',
|
||||
'website.specialItems.ai.applying': 'در حال اعمال…',
|
||||
'website.specialItems.ai.errorApply': 'اعمال محصولات تطبیقدادهشده ممکن نشد.',
|
||||
'website.specialItems.ai.toastCleared': 'ردیف خالی شد.',
|
||||
'website.specialItems.ai.toastApplied': '{count} محصول به ردیف افزوده شد.',
|
||||
'website.specialItems.ai.toastAppliedWithPrice':
|
||||
'{count} محصول افزوده و {priced} قیمت بهروز شد.',
|
||||
|
||||
'website.settings.title': 'تنظیمات وبسایت',
|
||||
'website.settings.subtitle':
|
||||
|
||||
@@ -46,6 +46,7 @@ const STAT_ROWS: {
|
||||
}[] = [
|
||||
{ id: 'all', titleKey: 'statistics.row.all' },
|
||||
{ id: 'home', titleKey: 'statistics.row.home' },
|
||||
{ id: 'pages', titleKey: 'statistics.row.pages' },
|
||||
{
|
||||
id: 'products',
|
||||
titleKey: 'statistics.row.products',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EditStoreItemsModal } from '../components/EditStoreItemsModal'
|
||||
import { PickStoreSpecialItemsModal } from '../components/PickStoreSpecialItemsModal'
|
||||
import { PickStoreVariantModal } from '../components/PickStoreVariantModal'
|
||||
import { ShoppingCartModal } from '../components/ShoppingCartModal'
|
||||
import { SpecialItemsAiModal } from '../components/SpecialItemsAiModal'
|
||||
import { StoreItemDiscountModal } from '../components/StoreItemDiscountModal'
|
||||
import { StoreItemFestivalModal } from '../components/StoreItemFestivalModal'
|
||||
import { StoreSpecialCarousel } from '../components/StoreSpecialCarousel'
|
||||
@@ -59,6 +60,7 @@ export function StoreSpecialsPage() {
|
||||
const [editSpecialTarget, setEditSpecialTarget] = useState<StoreSpecial | null>(null)
|
||||
const [deleteSpecialTarget, setDeleteSpecialTarget] = useState<StoreSpecial | null>(null)
|
||||
const [pickItemsTarget, setPickItemsTarget] = useState<StoreSpecial | null>(null)
|
||||
const [aiTarget, setAiTarget] = useState<StoreSpecial | null>(null)
|
||||
|
||||
const [editListingTarget, setEditListingTarget] = useState<StoreProductListing | null>(null)
|
||||
const [discountTarget, setDiscountTarget] = useState<StoreProductListing | null>(null)
|
||||
@@ -383,6 +385,7 @@ export function StoreSpecialsPage() {
|
||||
onAddItems={setPickItemsTarget}
|
||||
onEditSpecial={setEditSpecialTarget}
|
||||
onDeleteSpecial={setDeleteSpecialTarget}
|
||||
onAiAssist={setAiTarget}
|
||||
onOpenListing={setEditListingTarget}
|
||||
onEditListing={setEditListingTarget}
|
||||
onDiscountListing={setDiscountTarget}
|
||||
@@ -459,6 +462,35 @@ export function StoreSpecialsPage() {
|
||||
isSubmitting={isSaving}
|
||||
/>
|
||||
|
||||
<SpecialItemsAiModal
|
||||
open={!!aiTarget}
|
||||
special={aiTarget}
|
||||
source={specialSource}
|
||||
onClose={() => setAiTarget(null)}
|
||||
onApplied={({ special, added, priced, cleared }) => {
|
||||
replaceSpecial(special)
|
||||
setAiTarget(null)
|
||||
if (cleared && added === 0) {
|
||||
showToast(t('website.specialItems.ai.toastCleared'), 'success')
|
||||
return
|
||||
}
|
||||
if (priced > 0) {
|
||||
showToast(
|
||||
t('website.specialItems.ai.toastAppliedWithPrice', {
|
||||
count: added,
|
||||
priced,
|
||||
}),
|
||||
'success',
|
||||
)
|
||||
return
|
||||
}
|
||||
showToast(
|
||||
t('website.specialItems.ai.toastApplied', { count: added }),
|
||||
'success',
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditStoreItemsModal
|
||||
open={!!editListingTarget}
|
||||
productTitle={editListingTarget?.productTitle ?? ''}
|
||||
|
||||
@@ -43,6 +43,7 @@ export async function getCustomersDailyActivity(days = 30, signal?: AbortSignal)
|
||||
export type WebsiteViewKind =
|
||||
| 'home'
|
||||
| 'all'
|
||||
| 'static_page'
|
||||
| 'portfolio_list'
|
||||
| 'portfolio_detail'
|
||||
| 'product_list'
|
||||
@@ -79,6 +80,7 @@ export async function getViewDailyActivity(
|
||||
export type ViewSummaryGroupId =
|
||||
| 'all'
|
||||
| 'home'
|
||||
| 'pages'
|
||||
| 'products'
|
||||
| 'blog'
|
||||
| 'portfolio'
|
||||
|
||||
@@ -64,3 +64,38 @@ export async function deleteStoreSpecial(specialId: string) {
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
export interface SpecialItemsAiMatchCandidate {
|
||||
storeItemId: string
|
||||
productId: string
|
||||
productTitle: string
|
||||
productNameFa: string
|
||||
productImage: string | null
|
||||
variantId: string
|
||||
variantLabel: string
|
||||
currentPrice: number | null
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface SpecialItemsAiMatchRow {
|
||||
lineIndex: number
|
||||
raw: string
|
||||
price: number | null
|
||||
selected: boolean
|
||||
updatePrice: boolean
|
||||
confidence: number
|
||||
reason: string | null
|
||||
candidate: SpecialItemsAiMatchCandidate | null
|
||||
}
|
||||
|
||||
export async function matchStoreSpecialListWithAi(text: string, signal?: AbortSignal) {
|
||||
return apiRequest<{ message: string; matches: SpecialItemsAiMatchRow[] }>(
|
||||
businessPath('/ai-match'),
|
||||
{
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: { text },
|
||||
signal,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
@@ -559,21 +559,6 @@
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.thSms,
|
||||
.tdSms {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tdSms {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.thModules,
|
||||
.tdModules {
|
||||
width: 1%;
|
||||
@@ -652,6 +637,10 @@
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.modulesChartsRow > .field + .field {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.modulesChartsRow {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -1560,7 +1560,6 @@ export function BusinessesPage() {
|
||||
<th className={styles.th}>Contract Data / Renewal</th>
|
||||
<th className={styles.th}>Domain</th>
|
||||
<th className={styles.th}>Owner</th>
|
||||
<th className={`${styles.th} ${styles.thSms}`}>SMS sent</th>
|
||||
<th className={`${styles.th} ${styles.thTheme}`}>Theme</th>
|
||||
<th className={`${styles.th} ${styles.thLocale}`}>Lang</th>
|
||||
<th className={`${styles.th} ${styles.thModules}`}>Modules</th>
|
||||
@@ -1570,7 +1569,7 @@ export function BusinessesPage() {
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={9}>
|
||||
<td className={styles.td} colSpan={8}>
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1578,7 +1577,7 @@ export function BusinessesPage() {
|
||||
|
||||
{!loading && data?.items?.length === 0 && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={9}>
|
||||
<td className={styles.td} colSpan={8}>
|
||||
No results found.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1669,9 +1668,6 @@ export function BusinessesPage() {
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`${styles.td} ${styles.tdSms}`}>
|
||||
{(b.smsSentCount ?? 0).toLocaleString('en-US')}
|
||||
</td>
|
||||
<td className={`${styles.td} ${styles.tdTheme}`}>
|
||||
<PrimaryColorSwatchControl
|
||||
value={normalizeBusinessPrimaryColorId(b.primaryColor)}
|
||||
|
||||
Reference in New Issue
Block a user