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>
407 lines
13 KiB
TypeScript
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,
|
|
)
|
|
}
|