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