Files
dashboards/apps/business/src/components/SpecialItemsAiModal.tsx
T
Alireza HassaniandCursor 652f4d07b6 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>
2026-09-16 17:33:44 +03:30

407 lines
13 KiB
TypeScript

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,
)
}