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,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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user