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 { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
@@ -15,6 +15,7 @@ interface StoreSpecialCarouselProps {
onAddItems: (special: StoreSpecial) => void
onEditSpecial: (special: StoreSpecial) => void
onDeleteSpecial: (special: StoreSpecial) => void
onAiAssist: (special: StoreSpecial) => void
onOpenListing: (listing: StoreProductListing) => void
onEditListing: (listing: StoreProductListing) => void
onDiscountListing: (listing: StoreProductListing) => void
@@ -28,6 +29,7 @@ export function StoreSpecialCarousel({
onAddItems,
onEditSpecial,
onDeleteSpecial,
onAiAssist,
onOpenListing,
onEditListing,
onDiscountListing,
@@ -94,6 +96,16 @@ export function StoreSpecialCarousel({
</span>
</div>
<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')}>
<button
type="button"