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 { useRef } from 'react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
@@ -15,6 +15,7 @@ interface StoreSpecialCarouselProps {
|
|||||||
onAddItems: (special: StoreSpecial) => void
|
onAddItems: (special: StoreSpecial) => void
|
||||||
onEditSpecial: (special: StoreSpecial) => void
|
onEditSpecial: (special: StoreSpecial) => void
|
||||||
onDeleteSpecial: (special: StoreSpecial) => void
|
onDeleteSpecial: (special: StoreSpecial) => void
|
||||||
|
onAiAssist: (special: StoreSpecial) => void
|
||||||
onOpenListing: (listing: StoreProductListing) => void
|
onOpenListing: (listing: StoreProductListing) => void
|
||||||
onEditListing: (listing: StoreProductListing) => void
|
onEditListing: (listing: StoreProductListing) => void
|
||||||
onDiscountListing: (listing: StoreProductListing) => void
|
onDiscountListing: (listing: StoreProductListing) => void
|
||||||
@@ -28,6 +29,7 @@ export function StoreSpecialCarousel({
|
|||||||
onAddItems,
|
onAddItems,
|
||||||
onEditSpecial,
|
onEditSpecial,
|
||||||
onDeleteSpecial,
|
onDeleteSpecial,
|
||||||
|
onAiAssist,
|
||||||
onOpenListing,
|
onOpenListing,
|
||||||
onEditListing,
|
onEditListing,
|
||||||
onDiscountListing,
|
onDiscountListing,
|
||||||
@@ -94,6 +96,16 @@ export function StoreSpecialCarousel({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.headerActions}>
|
<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')}>
|
<Tooltip label={t('website.specialItems.editCategory')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ const en = {
|
|||||||
'statistics.errorLoad': 'Unable to load visit statistics.',
|
'statistics.errorLoad': 'Unable to load visit statistics.',
|
||||||
'statistics.row.all': 'Total visit',
|
'statistics.row.all': 'Total visit',
|
||||||
'statistics.row.home': 'Home page',
|
'statistics.row.home': 'Home page',
|
||||||
|
'statistics.row.pages': 'Other pages',
|
||||||
'statistics.row.products': 'Products',
|
'statistics.row.products': 'Products',
|
||||||
'statistics.row.blog': 'Blog',
|
'statistics.row.blog': 'Blog',
|
||||||
'statistics.row.portfolio': 'Portfolio',
|
'statistics.row.portfolio': 'Portfolio',
|
||||||
@@ -1994,6 +1995,37 @@ const en = {
|
|||||||
'website.specialItems.pickSearchProducts': 'Type 3+ characters to search products',
|
'website.specialItems.pickSearchProducts': 'Type 3+ characters to search products',
|
||||||
'website.specialItems.pickErrorLoad': 'Unable to load store items.',
|
'website.specialItems.pickErrorLoad': 'Unable to load store items.',
|
||||||
'website.specialItems.pickErrorLoadProducts': 'Unable to load products.',
|
'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.title': 'Website settings',
|
||||||
'website.settings.subtitle':
|
'website.settings.subtitle':
|
||||||
@@ -2812,6 +2844,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'statistics.errorLoad': 'بارگذاری آمار بازدید ممکن نشد.',
|
'statistics.errorLoad': 'بارگذاری آمار بازدید ممکن نشد.',
|
||||||
'statistics.row.all': 'بازدید کل',
|
'statistics.row.all': 'بازدید کل',
|
||||||
'statistics.row.home': 'صفحه اصلی',
|
'statistics.row.home': 'صفحه اصلی',
|
||||||
|
'statistics.row.pages': 'سایر صفحات',
|
||||||
'statistics.row.products': 'محصولات',
|
'statistics.row.products': 'محصولات',
|
||||||
'statistics.row.blog': 'بلاگ',
|
'statistics.row.blog': 'بلاگ',
|
||||||
'statistics.row.portfolio': 'نمونه کار',
|
'statistics.row.portfolio': 'نمونه کار',
|
||||||
@@ -4501,6 +4534,38 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'website.specialItems.pickSearchProducts': 'حداقل ۳ حرف برای جستجوی محصولات تایپ کنید',
|
'website.specialItems.pickSearchProducts': 'حداقل ۳ حرف برای جستجوی محصولات تایپ کنید',
|
||||||
'website.specialItems.pickErrorLoad': 'بارگذاری محصولات فروشگاه ممکن نشد.',
|
'website.specialItems.pickErrorLoad': 'بارگذاری محصولات فروشگاه ممکن نشد.',
|
||||||
'website.specialItems.pickErrorLoadProducts': 'بارگذاری محصولات ممکن نشد.',
|
'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.title': 'تنظیمات وبسایت',
|
||||||
'website.settings.subtitle':
|
'website.settings.subtitle':
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const STAT_ROWS: {
|
|||||||
}[] = [
|
}[] = [
|
||||||
{ id: 'all', titleKey: 'statistics.row.all' },
|
{ id: 'all', titleKey: 'statistics.row.all' },
|
||||||
{ id: 'home', titleKey: 'statistics.row.home' },
|
{ id: 'home', titleKey: 'statistics.row.home' },
|
||||||
|
{ id: 'pages', titleKey: 'statistics.row.pages' },
|
||||||
{
|
{
|
||||||
id: 'products',
|
id: 'products',
|
||||||
titleKey: 'statistics.row.products',
|
titleKey: 'statistics.row.products',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { EditStoreItemsModal } from '../components/EditStoreItemsModal'
|
|||||||
import { PickStoreSpecialItemsModal } from '../components/PickStoreSpecialItemsModal'
|
import { PickStoreSpecialItemsModal } from '../components/PickStoreSpecialItemsModal'
|
||||||
import { PickStoreVariantModal } from '../components/PickStoreVariantModal'
|
import { PickStoreVariantModal } from '../components/PickStoreVariantModal'
|
||||||
import { ShoppingCartModal } from '../components/ShoppingCartModal'
|
import { ShoppingCartModal } from '../components/ShoppingCartModal'
|
||||||
|
import { SpecialItemsAiModal } from '../components/SpecialItemsAiModal'
|
||||||
import { StoreItemDiscountModal } from '../components/StoreItemDiscountModal'
|
import { StoreItemDiscountModal } from '../components/StoreItemDiscountModal'
|
||||||
import { StoreItemFestivalModal } from '../components/StoreItemFestivalModal'
|
import { StoreItemFestivalModal } from '../components/StoreItemFestivalModal'
|
||||||
import { StoreSpecialCarousel } from '../components/StoreSpecialCarousel'
|
import { StoreSpecialCarousel } from '../components/StoreSpecialCarousel'
|
||||||
@@ -59,6 +60,7 @@ export function StoreSpecialsPage() {
|
|||||||
const [editSpecialTarget, setEditSpecialTarget] = useState<StoreSpecial | null>(null)
|
const [editSpecialTarget, setEditSpecialTarget] = useState<StoreSpecial | null>(null)
|
||||||
const [deleteSpecialTarget, setDeleteSpecialTarget] = useState<StoreSpecial | null>(null)
|
const [deleteSpecialTarget, setDeleteSpecialTarget] = useState<StoreSpecial | null>(null)
|
||||||
const [pickItemsTarget, setPickItemsTarget] = 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 [editListingTarget, setEditListingTarget] = useState<StoreProductListing | null>(null)
|
||||||
const [discountTarget, setDiscountTarget] = useState<StoreProductListing | null>(null)
|
const [discountTarget, setDiscountTarget] = useState<StoreProductListing | null>(null)
|
||||||
@@ -383,6 +385,7 @@ export function StoreSpecialsPage() {
|
|||||||
onAddItems={setPickItemsTarget}
|
onAddItems={setPickItemsTarget}
|
||||||
onEditSpecial={setEditSpecialTarget}
|
onEditSpecial={setEditSpecialTarget}
|
||||||
onDeleteSpecial={setDeleteSpecialTarget}
|
onDeleteSpecial={setDeleteSpecialTarget}
|
||||||
|
onAiAssist={setAiTarget}
|
||||||
onOpenListing={setEditListingTarget}
|
onOpenListing={setEditListingTarget}
|
||||||
onEditListing={setEditListingTarget}
|
onEditListing={setEditListingTarget}
|
||||||
onDiscountListing={setDiscountTarget}
|
onDiscountListing={setDiscountTarget}
|
||||||
@@ -459,6 +462,35 @@ export function StoreSpecialsPage() {
|
|||||||
isSubmitting={isSaving}
|
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
|
<EditStoreItemsModal
|
||||||
open={!!editListingTarget}
|
open={!!editListingTarget}
|
||||||
productTitle={editListingTarget?.productTitle ?? ''}
|
productTitle={editListingTarget?.productTitle ?? ''}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export async function getCustomersDailyActivity(days = 30, signal?: AbortSignal)
|
|||||||
export type WebsiteViewKind =
|
export type WebsiteViewKind =
|
||||||
| 'home'
|
| 'home'
|
||||||
| 'all'
|
| 'all'
|
||||||
|
| 'static_page'
|
||||||
| 'portfolio_list'
|
| 'portfolio_list'
|
||||||
| 'portfolio_detail'
|
| 'portfolio_detail'
|
||||||
| 'product_list'
|
| 'product_list'
|
||||||
@@ -79,6 +80,7 @@ export async function getViewDailyActivity(
|
|||||||
export type ViewSummaryGroupId =
|
export type ViewSummaryGroupId =
|
||||||
| 'all'
|
| 'all'
|
||||||
| 'home'
|
| 'home'
|
||||||
|
| 'pages'
|
||||||
| 'products'
|
| 'products'
|
||||||
| 'blog'
|
| 'blog'
|
||||||
| 'portfolio'
|
| 'portfolio'
|
||||||
|
|||||||
@@ -64,3 +64,38 @@ export async function deleteStoreSpecial(specialId: string) {
|
|||||||
auth: true,
|
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;
|
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,
|
.thModules,
|
||||||
.tdModules {
|
.tdModules {
|
||||||
width: 1%;
|
width: 1%;
|
||||||
@@ -652,6 +637,10 @@
|
|||||||
gap: 12px 16px;
|
gap: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modulesChartsRow > .field + .field {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.modulesChartsRow {
|
.modulesChartsRow {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -1560,7 +1560,6 @@ export function BusinessesPage() {
|
|||||||
<th className={styles.th}>Contract Data / Renewal</th>
|
<th className={styles.th}>Contract Data / Renewal</th>
|
||||||
<th className={styles.th}>Domain</th>
|
<th className={styles.th}>Domain</th>
|
||||||
<th className={styles.th}>Owner</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.thTheme}`}>Theme</th>
|
||||||
<th className={`${styles.th} ${styles.thLocale}`}>Lang</th>
|
<th className={`${styles.th} ${styles.thLocale}`}>Lang</th>
|
||||||
<th className={`${styles.th} ${styles.thModules}`}>Modules</th>
|
<th className={`${styles.th} ${styles.thModules}`}>Modules</th>
|
||||||
@@ -1570,7 +1569,7 @@ export function BusinessesPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{loading && (
|
{loading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className={styles.td} colSpan={9}>
|
<td className={styles.td} colSpan={8}>
|
||||||
Loading...
|
Loading...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1578,7 +1577,7 @@ export function BusinessesPage() {
|
|||||||
|
|
||||||
{!loading && data?.items?.length === 0 && (
|
{!loading && data?.items?.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className={styles.td} colSpan={9}>
|
<td className={styles.td} colSpan={8}>
|
||||||
No results found.
|
No results found.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1669,9 +1668,6 @@ export function BusinessesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={`${styles.td} ${styles.tdSms}`}>
|
|
||||||
{(b.smsSentCount ?? 0).toLocaleString('en-US')}
|
|
||||||
</td>
|
|
||||||
<td className={`${styles.td} ${styles.tdTheme}`}>
|
<td className={`${styles.td} ${styles.tdTheme}`}>
|
||||||
<PrimaryColorSwatchControl
|
<PrimaryColorSwatchControl
|
||||||
value={normalizeBusinessPrimaryColorId(b.primaryColor)}
|
value={normalizeBusinessPrimaryColorId(b.primaryColor)}
|
||||||
|
|||||||
Reference in New Issue
Block a user