Show other-pages stats and store-specials AI matching.

Add the Statistics pages row for static_page views, wire specials AI match UI, and tidy Businesses page styles.

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