Improve product AI UX, category search, and card actions.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
d787a69195
commit
b743c09e4d
@@ -1,3 +1,7 @@
|
||||
.modal {
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.headerBlock {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -10,6 +14,20 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.promptHint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Sparkles, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
|
||||
import { createProductByAi, type ProductAiLanguage } from '../services/productAiService'
|
||||
@@ -26,26 +28,29 @@ export function AddProductByAiModal({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: AddProductByAiModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const { showToast } = useToast()
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [language, setLanguage] = useState<ProductAiLanguage>('en')
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [language, setLanguage] = useState<ProductAiLanguage>(locale === 'fa' ? 'fa' : 'en')
|
||||
const [isLoadingCategories, setIsLoadingCategories] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const categoryOptions = useMemo(() => flattenCategories(categories), [categories])
|
||||
const promptDir = language === 'fa' ? 'rtl' : 'ltr'
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
setCategoryId('')
|
||||
setName('')
|
||||
setLanguage('en')
|
||||
setPrompt('')
|
||||
setLanguage(locale === 'fa' ? 'fa' : 'en')
|
||||
setError('')
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
@@ -55,7 +60,7 @@ export function AddProductByAiModal({
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, mounted])
|
||||
}, [open, mounted, locale])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -86,7 +91,7 @@ export function AddProductByAiModal({
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load categories.')
|
||||
setError(t('products.ai.loadCategoriesError'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingCategories(false)
|
||||
@@ -95,7 +100,7 @@ export function AddProductByAiModal({
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (!categoryId || !name.trim()) return
|
||||
if (!categoryId || !prompt.trim()) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
@@ -103,7 +108,7 @@ export function AddProductByAiModal({
|
||||
try {
|
||||
const result = await createProductByAi({
|
||||
categoryId,
|
||||
name: name.trim(),
|
||||
name: prompt.trim(),
|
||||
language,
|
||||
})
|
||||
showToast(result.message, 'success')
|
||||
@@ -115,7 +120,7 @@ export function AddProductByAiModal({
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to create product with AI.')
|
||||
setError(t('products.ai.createError'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -131,7 +136,7 @@ export function AddProductByAiModal({
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className={`${modalStyles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
||||
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="add-product-ai-title"
|
||||
@@ -139,19 +144,16 @@ export function AddProductByAiModal({
|
||||
<div className={`${modalStyles.header} ${styles.header}`}>
|
||||
<div className={styles.headerBlock}>
|
||||
<h2 id="add-product-ai-title" className={modalStyles.title}>
|
||||
Add Product by AI
|
||||
{t('products.ai.title')}
|
||||
</h2>
|
||||
<p className={styles.subtitle}>
|
||||
AI will generate product content and technical data. You can add images and
|
||||
variations afterward.
|
||||
</p>
|
||||
<p className={styles.subtitle}>{t('products.ai.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
aria-label="Close"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -159,43 +161,44 @@ export function AddProductByAiModal({
|
||||
|
||||
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
|
||||
<div className={modalStyles.field}>
|
||||
<label>Category</label>
|
||||
<label htmlFor="ai-product-category">{t('products.ai.category')}</label>
|
||||
<SearchableSelect
|
||||
options={categoryOptions}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder="Search and select category..."
|
||||
placeholder={t('products.ai.categoryPlaceholder')}
|
||||
allowEmpty={false}
|
||||
dropdownVariant="inFlow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="ai-product-name">Product name</label>
|
||||
<input
|
||||
id="ai-product-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={
|
||||
language === 'fa' ? 'نام محصول را وارد کنید' : 'Enter product name'
|
||||
}
|
||||
dir={language === 'fa' ? 'rtl' : 'ltr'}
|
||||
className={language === 'fa' ? 'faText' : undefined}
|
||||
<label htmlFor="ai-product-prompt">{t('products.ai.prompt')}</label>
|
||||
<textarea
|
||||
id="ai-product-prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={t('products.ai.promptPlaceholder')}
|
||||
dir={promptDir}
|
||||
className={`${styles.prompt}${language === 'fa' ? ' faText' : ''}`}
|
||||
disabled={isSubmitting}
|
||||
rows={5}
|
||||
required
|
||||
minLength={2}
|
||||
/>
|
||||
<p className={styles.promptHint}>{t('products.ai.promptHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className={`${modalStyles.field} ${fieldStyles.field}`}>
|
||||
<label htmlFor="ai-product-language">Language</label>
|
||||
<label htmlFor="ai-product-language">{t('products.ai.language')}</label>
|
||||
<select
|
||||
id="ai-product-language"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value as ProductAiLanguage)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="fa">Persian (Farsi)</option>
|
||||
<option value="en">{t('products.ai.language.en')}</option>
|
||||
<option value="fa">{t('products.ai.language.fa')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -208,24 +211,24 @@ export function AddProductByAiModal({
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={aiStyles.aiBtn}
|
||||
disabled={
|
||||
isSubmitting || isLoadingCategories || !categoryId || !name.trim()
|
||||
isSubmitting || isLoadingCategories || !categoryId || !prompt.trim()
|
||||
}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Sparkles size={16} />
|
||||
Creating...
|
||||
{t('products.ai.creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={16} />
|
||||
Create product
|
||||
{t('products.ai.create')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.modal {
|
||||
max-width: 560px;
|
||||
width: min(820px, calc(100vw - 32px));
|
||||
max-width: 820px;
|
||||
}
|
||||
|
||||
.headerBlock {
|
||||
|
||||
@@ -12,7 +12,11 @@ export function buildCategoryAiPrompt(locale: 'fa' | 'en') {
|
||||
if (locale === 'fa') {
|
||||
return `یک درخت دستهبندی محصول کاربردی برای فروشگاه من بساز.
|
||||
|
||||
نام انگلیسی، نام فارسی با خط پارسی، و توضیح کوتاه انگلیسی را برای هر دسته اضافه کن.
|
||||
برای هر دسته این موارد را مشخص کن:
|
||||
- نام انگلیسی
|
||||
- نام فارسی با خط پارسی
|
||||
- توضیح کوتاه فارسی
|
||||
|
||||
۳ تا ۵ دسته اصلی با زیردستههای مرتبط بساز تا مرور برای خریداران راحتتر شود.`
|
||||
}
|
||||
|
||||
@@ -136,7 +140,7 @@ export function CategoryAiPromptModal({
|
||||
className={aiStyles.aiPromptTextarea}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={8}
|
||||
rows={14}
|
||||
disabled={isRunning}
|
||||
required
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
|
||||
@@ -202,7 +202,8 @@
|
||||
|
||||
.variantBadge,
|
||||
.commentBadge,
|
||||
.translateBadge {
|
||||
.translateBadge,
|
||||
.actionBadge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
@@ -241,3 +242,12 @@
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.menuItemDanger {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.menuItemDanger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Pencil,
|
||||
Globe,
|
||||
Info,
|
||||
Trash2,
|
||||
Layers,
|
||||
MessageSquare,
|
||||
ClipboardList,
|
||||
ImageOff,
|
||||
ExternalLink,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { Product } from '../types/product'
|
||||
@@ -19,29 +19,37 @@ import { hasBusinessModule } from '../utils/businessModules'
|
||||
import { formatProductCategoryChip } from '../services/productService'
|
||||
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
||||
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
|
||||
import { ProductCardOverflowMenu } from './ProductCardOverflowMenu'
|
||||
import styles from './ProductCard.module.css'
|
||||
|
||||
/** Max icon slots in the card footer, including the overflow trigger when needed. */
|
||||
const MAX_VISIBLE_ACTIONS = 6
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product
|
||||
commentCount: number
|
||||
variantCount: number
|
||||
onEdit: (id: string) => void
|
||||
onTranslate: (id: string) => void
|
||||
onQuickInfo: (id: string) => void
|
||||
onComments: (id: string) => void
|
||||
onTechnicalInfo: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
onVariations: (id: string) => void
|
||||
}
|
||||
|
||||
type CardAction = {
|
||||
key: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
onClick: () => void
|
||||
danger?: boolean
|
||||
badge?: number
|
||||
render?: () => ReactNode
|
||||
}
|
||||
|
||||
export function ProductCard({
|
||||
product,
|
||||
commentCount,
|
||||
variantCount,
|
||||
onEdit,
|
||||
onTranslate,
|
||||
onQuickInfo,
|
||||
onComments,
|
||||
onTechnicalInfo,
|
||||
onRemove,
|
||||
onVariations,
|
||||
@@ -68,6 +76,69 @@ export function ProductCard({
|
||||
setImageFailed(false)
|
||||
}, [imageSrc])
|
||||
|
||||
const actions: CardAction[] = [
|
||||
{
|
||||
key: 'edit',
|
||||
label: t('products.card.edit'),
|
||||
icon: Pencil,
|
||||
onClick: () => onEdit(product.id),
|
||||
},
|
||||
{
|
||||
key: 'website',
|
||||
label: t('common.viewOnWebsite'),
|
||||
icon: ExternalLink,
|
||||
onClick: () => {
|
||||
if (websiteUrl) window.open(websiteUrl, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
render: () => <OpenOnWebsiteControl href={websiteUrl} />,
|
||||
},
|
||||
...(showTranslationControls
|
||||
? [
|
||||
{
|
||||
key: 'translate',
|
||||
label: t('products.card.translate'),
|
||||
icon: Globe,
|
||||
onClick: () => onTranslate(product.id),
|
||||
badge: translationCount > 0 ? translationCount : undefined,
|
||||
} satisfies CardAction,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'variations',
|
||||
label: t('products.card.variations'),
|
||||
icon: Layers,
|
||||
onClick: () => onVariations(product.id),
|
||||
badge: variantCount > 0 ? variantCount : undefined,
|
||||
},
|
||||
{
|
||||
key: 'technical',
|
||||
label: t('products.card.technical'),
|
||||
icon: ClipboardList,
|
||||
onClick: () => onTechnicalInfo(product.id),
|
||||
},
|
||||
{
|
||||
key: 'remove',
|
||||
label: t('products.card.remove'),
|
||||
icon: Trash2,
|
||||
onClick: () => onRemove(product.id),
|
||||
danger: true,
|
||||
},
|
||||
]
|
||||
|
||||
const needsOverflow = actions.length > MAX_VISIBLE_ACTIONS
|
||||
const visibleActions = needsOverflow
|
||||
? actions.slice(0, MAX_VISIBLE_ACTIONS - 1)
|
||||
: actions
|
||||
const overflowActions = needsOverflow
|
||||
? actions.slice(MAX_VISIBLE_ACTIONS - 1).map(({ key, label, icon, onClick, danger }) => ({
|
||||
key,
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
danger,
|
||||
}))
|
||||
: []
|
||||
|
||||
return (
|
||||
<article className={styles.card}>
|
||||
<Link to={`/products/detail/${product.id}`} className={styles.clickable}>
|
||||
@@ -109,83 +180,34 @@ export function ProductCard({
|
||||
</Link>
|
||||
|
||||
<div className={styles.controls}>
|
||||
<Tooltip label={t('products.card.edit')}>
|
||||
<button type="button" onClick={() => onEdit(product.id)} aria-label={t('products.card.edit')}>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<OpenOnWebsiteControl href={websiteUrl} />
|
||||
{showTranslationControls ? (
|
||||
<Tooltip label={t('products.card.translate')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
onClick={() => onTranslate(product.id)}
|
||||
aria-label={
|
||||
translationCount > 0
|
||||
? `${t('products.card.translate')} (${translationCount})`
|
||||
: t('products.card.translate')
|
||||
}
|
||||
>
|
||||
<Globe size={16} />
|
||||
{translationCount > 0 && (
|
||||
<span className={styles.translateBadge}>{translationCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{visibleActions.map((action) => {
|
||||
if (action.render) return <span key={action.key}>{action.render()}</span>
|
||||
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<Tooltip key={action.key} label={action.label}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconBtn}${action.danger ? ` ${styles.danger}` : ''}`}
|
||||
onClick={action.onClick}
|
||||
aria-label={
|
||||
action.badge != null ? `${action.label} (${action.badge})` : action.label
|
||||
}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{action.badge != null ? (
|
||||
<span className={styles.actionBadge}>{action.badge}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{overflowActions.length > 0 ? (
|
||||
<ProductCardOverflowMenu
|
||||
productName={primaryName}
|
||||
items={overflowActions}
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip label={t('products.card.quickInfo')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onQuickInfo(product.id)}
|
||||
aria-label={t('products.card.quickInfo')}
|
||||
>
|
||||
<Info size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('products.card.variations')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
onClick={() => onVariations(product.id)}
|
||||
aria-label={`${t('products.card.variations')} (${variantCount})`}
|
||||
>
|
||||
<Layers size={16} />
|
||||
{variantCount > 0 && <span className={styles.variantBadge}>{variantCount}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('products.card.comments')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
onClick={() => onComments(product.id)}
|
||||
aria-label={`${t('products.card.comments')} (${commentCount})`}
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
{commentCount > 0 && (
|
||||
<span className={styles.commentBadge}>{commentCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('products.card.technical')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTechnicalInfo(product.id)}
|
||||
aria-label={t('products.card.technical')}
|
||||
>
|
||||
<ClipboardList size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('products.card.remove')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.danger}
|
||||
onClick={() => onRemove(product.id)}
|
||||
aria-label={t('products.card.remove')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MoreHorizontal, type LucideIcon } from 'lucide-react'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { fitFixedPopover } from '../utils/fitFixedPopover'
|
||||
import menuStyles from './CustomerActionsOverflowMenu.module.css'
|
||||
import cardStyles from './ProductCard.module.css'
|
||||
|
||||
type PopoverPosition = {
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
|
||||
export type ProductCardOverflowItem = {
|
||||
key: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
onClick: () => void
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
interface ProductCardOverflowMenuProps {
|
||||
productName: string
|
||||
items: ProductCardOverflowItem[]
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function ProductCardOverflowMenu({
|
||||
productName,
|
||||
items,
|
||||
disabled = false,
|
||||
}: ProductCardOverflowMenuProps) {
|
||||
const t = useT()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [popoverPos, setPopoverPos] = useState<PopoverPosition | null>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
function updatePopoverPosition() {
|
||||
const rect = triggerRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
||||
const size = popoverRef.current
|
||||
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
||||
: { width: 200, height: 44 * Math.max(items.length, 1) + 12 }
|
||||
|
||||
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
if (disabled || items.length === 0) return
|
||||
if (open) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
updatePopoverPosition()
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
updatePopoverPosition()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node
|
||||
if (wrapRef.current?.contains(target)) return
|
||||
if (popoverRef.current?.contains(target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
|
||||
function handleReposition() {
|
||||
updatePopoverPosition()
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
window.addEventListener('resize', handleReposition)
|
||||
window.addEventListener('scroll', handleReposition, true)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
window.removeEventListener('resize', handleReposition)
|
||||
window.removeEventListener('scroll', handleReposition, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
const moreLabel = t('products.card.moreActions')
|
||||
|
||||
return (
|
||||
<div className={menuStyles.wrap} ref={wrapRef}>
|
||||
<Tooltip label={moreLabel}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`${cardStyles.iconBtn} ${open ? menuStyles.triggerOpen : ''}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggleOpen()
|
||||
}}
|
||||
aria-label={`${moreLabel}: ${productName}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{open &&
|
||||
popoverPos &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={menuStyles.menu}
|
||||
role="menu"
|
||||
aria-label={`${moreLabel}: ${productName}`}
|
||||
style={{
|
||||
top: popoverPos.top,
|
||||
left: popoverPos.left,
|
||||
}}
|
||||
>
|
||||
{items.map(({ key, label, icon: Icon, onClick, danger }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`${menuStyles.item}${danger ? ` ${cardStyles.menuItemDanger}` : ''}`}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -239,7 +239,7 @@ export function ProductFieldAiPromptModal({
|
||||
className={aiStyles.aiPromptTextarea}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={10}
|
||||
rows={14}
|
||||
disabled={isRunning}
|
||||
required
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
|
||||
@@ -15,12 +15,13 @@ export function buildTechnicalFormAiPrompt(
|
||||
return `برای دستهبندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده.
|
||||
|
||||
برای هر فیلد این موارد را مشخص کن:
|
||||
- برچسب کوتاه انگلیسی (مثلاً Screen Size، Material، Color)
|
||||
- برچسب کوتاه فارسی (مثلاً سایز صفحه نمایش، جنس بدنه، رنگها)
|
||||
- نوع ورودی: text، textarea، select یا multi_select
|
||||
- الزامی بودن یا نبودن
|
||||
- برای select / multi_select: فهرست گزینهها
|
||||
- برای select / multi_select: فهرست گزینهها به فارسی
|
||||
|
||||
روی مشخصات و ویژگیهایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی میکنند.`
|
||||
روی مشخصات و ویژگیهایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی میکنند.
|
||||
برچسبها و گزینهها را به فارسی بنویس.`
|
||||
}
|
||||
|
||||
return `Suggest technical data form fields for the "${categoryName}" product category.
|
||||
@@ -147,7 +148,7 @@ export function TechnicalFormAiPromptModal({
|
||||
className={aiStyles.aiPromptTextarea}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={8}
|
||||
rows={14}
|
||||
disabled={isRunning}
|
||||
required
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
|
||||
@@ -350,6 +350,23 @@ const en = {
|
||||
'products.list.deleteMessage':
|
||||
'Are you sure you want to delete "{name}"? This action cannot be undone.',
|
||||
'products.list.addByAi': 'Add product by AI',
|
||||
'products.ai.title': 'Add product by AI',
|
||||
'products.ai.subtitle':
|
||||
'AI will generate product content and technical data. You can add images and variations afterward.',
|
||||
'products.ai.category': 'Category',
|
||||
'products.ai.categoryPlaceholder': 'Search and select category...',
|
||||
'products.ai.prompt': 'Prompt',
|
||||
'products.ai.promptPlaceholder':
|
||||
'Example:\nPhilips Air Fryer HD9218/51\nhttps://www.philips.com/...',
|
||||
'products.ai.promptHint':
|
||||
'Include the product or model name, and optionally a product page link or extra notes for better results.',
|
||||
'products.ai.language': 'Language',
|
||||
'products.ai.language.en': 'English',
|
||||
'products.ai.language.fa': 'Persian (Farsi)',
|
||||
'products.ai.create': 'Create product',
|
||||
'products.ai.creating': 'Creating…',
|
||||
'products.ai.createError': 'Unable to create product with AI.',
|
||||
'products.ai.loadCategoriesError': 'Unable to load categories.',
|
||||
'products.list.aiMenu': 'Artificial Intelligence',
|
||||
'products.list.translateAll': 'Translate all products',
|
||||
'products.list.translateAll.pickTitle': 'Translate all products',
|
||||
@@ -535,6 +552,7 @@ const en = {
|
||||
|
||||
'products.card.edit': 'Edit product',
|
||||
'products.card.translate': 'Translate',
|
||||
'products.card.moreActions': 'More actions',
|
||||
'products.card.quickInfo': 'Quick info',
|
||||
'products.card.variations': 'Manage variations',
|
||||
'products.card.comments': 'View comments',
|
||||
@@ -631,6 +649,10 @@ const en = {
|
||||
'categories.page.subtitle': 'Manage your product categories and subcategories.',
|
||||
'categories.loading': 'Loading categories...',
|
||||
'categories.empty': 'You have not added any categories yet.',
|
||||
'categories.search': 'Search categories',
|
||||
'categories.searchPlaceholder': 'Search categories…',
|
||||
'categories.searchClear': 'Clear search',
|
||||
'categories.searchEmpty': 'No categories match your search.',
|
||||
'categories.add': 'Add Category',
|
||||
'categories.addSub': 'Add Sub Category',
|
||||
'categories.edit': 'Edit Category',
|
||||
@@ -2894,6 +2916,23 @@ const fa: Record<MessageKey, string> = {
|
||||
'products.list.deleteMessage':
|
||||
'آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.',
|
||||
'products.list.addByAi': 'افزودن محصول با هوش مصنوعی',
|
||||
'products.ai.title': 'افزودن محصول با هوش مصنوعی',
|
||||
'products.ai.subtitle':
|
||||
'هوش مصنوعی محتوای محصول و اطلاعات فنی را میسازد. بعداً میتوانید تصویر و تنوعها را اضافه کنید.',
|
||||
'products.ai.category': 'دستهبندی',
|
||||
'products.ai.categoryPlaceholder': 'جستجو و انتخاب دستهبندی...',
|
||||
'products.ai.prompt': 'پرامپت',
|
||||
'products.ai.promptPlaceholder':
|
||||
'مثال:\nسرخکن فیلیپس مدل HD9218/51\nhttps://www.philips.com/...',
|
||||
'products.ai.promptHint':
|
||||
'نام محصول یا مدل را بنویسید و در صورت نیاز لینک صفحه محصول یا توضیحات بیشتر را اضافه کنید تا نتیجه بهتر شود.',
|
||||
'products.ai.language': 'زبان',
|
||||
'products.ai.language.en': 'انگلیسی',
|
||||
'products.ai.language.fa': 'فارسی',
|
||||
'products.ai.create': 'ایجاد محصول',
|
||||
'products.ai.creating': 'در حال ایجاد…',
|
||||
'products.ai.createError': 'ایجاد محصول با هوش مصنوعی ممکن نشد.',
|
||||
'products.ai.loadCategoriesError': 'بارگذاری دستهبندیها ممکن نشد.',
|
||||
'products.list.aiMenu': 'هوش مصنوعی',
|
||||
'products.list.translateAll': 'ترجمه همه محصولات',
|
||||
'products.list.translateAll.pickTitle': 'ترجمه همه محصولات',
|
||||
@@ -3079,6 +3118,7 @@ const fa: Record<MessageKey, string> = {
|
||||
|
||||
'products.card.edit': 'ویرایش محصول',
|
||||
'products.card.translate': 'ترجمه',
|
||||
'products.card.moreActions': 'عملیات بیشتر',
|
||||
'products.card.quickInfo': 'اطلاعات سریع',
|
||||
'products.card.variations': 'مدیریت تنوعها',
|
||||
'products.card.comments': 'مشاهده نظرات',
|
||||
@@ -3173,6 +3213,10 @@ const fa: Record<MessageKey, string> = {
|
||||
'categories.page.subtitle': 'دستهبندیها و زیردستههای محصولات خود را مدیریت کنید.',
|
||||
'categories.loading': 'در حال بارگذاری دستهبندیها...',
|
||||
'categories.empty': 'هنوز دستهبندیای ثبت نکردهاید.',
|
||||
'categories.search': 'جستجوی دستهبندیها',
|
||||
'categories.searchPlaceholder': 'جستجوی دستهبندیها…',
|
||||
'categories.searchClear': 'پاک کردن جستجو',
|
||||
'categories.searchEmpty': 'دستهبندیای با این جستجو پیدا نشد.',
|
||||
'categories.add': 'افزودن دستهبندی',
|
||||
'categories.addSub': 'افزودن زیردسته',
|
||||
'categories.edit': 'ویرایش دستهبندی',
|
||||
|
||||
@@ -1,3 +1,67 @@
|
||||
.listSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.searchWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
inset-inline-start: 18px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
padding-inline: 52px 48px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--glass-shadow);
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
border-color: rgba(var(--primary-rgb) / 0.45);
|
||||
box-shadow:
|
||||
var(--glass-shadow),
|
||||
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.searchClear {
|
||||
position: absolute;
|
||||
inset-inline-end: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.searchClear:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.list {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { FolderTree, Globe, Plus, Sparkles } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FolderTree, Globe, Plus, Search, Sparkles, X } from 'lucide-react'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { CategoryModal } from '../components/CategoryModal'
|
||||
import { CategoryAiPromptModal } from '../components/CategoryAiPromptModal'
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
toCategoryFormPayload,
|
||||
updateProductCategory,
|
||||
} from '../services/productCategoryService'
|
||||
import { filterCategoriesByQuery, getAncestorIds } from '../utils/categories'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { translateAllProductCategories } from '../services/translationService'
|
||||
import type { TranslationLocale } from '../types/translation'
|
||||
@@ -52,6 +53,7 @@ export function CategoriesPage() {
|
||||
const { enabledModules } = useTenantBranding()
|
||||
const isMultiLanguageEnabled = hasBusinessModule(enabledModules, 'multilanguage_data')
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [defaultParentId, setDefaultParentId] = useState('')
|
||||
@@ -119,6 +121,32 @@ export function CategoriesPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const filteredCategories = useMemo(
|
||||
() => filterCategoriesByQuery(categories, searchQuery),
|
||||
[categories, searchQuery],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const query = searchQuery.trim()
|
||||
if (!query) return
|
||||
|
||||
const normalized = query.toLowerCase()
|
||||
const nextExpanded = new Set<string>()
|
||||
for (const category of categories) {
|
||||
const haystack = `${category.nameFa} ${category.nameEn}`.toLowerCase()
|
||||
if (!haystack.includes(normalized)) continue
|
||||
for (const ancestorId of getAncestorIds(categories, category.id)) {
|
||||
nextExpanded.add(ancestorId)
|
||||
}
|
||||
}
|
||||
if (nextExpanded.size === 0) return
|
||||
setExpandedIds((prev) => {
|
||||
const merged = new Set(prev)
|
||||
for (const id of nextExpanded) merged.add(id)
|
||||
return merged
|
||||
})
|
||||
}, [categories, searchQuery])
|
||||
|
||||
function openAiFabMenu() {
|
||||
if (aiMenuCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(aiMenuCloseTimerRef.current)
|
||||
@@ -549,30 +577,60 @@ export function CategoriesPage() {
|
||||
onAction={() => openCreateModal()}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
<CategoryTree
|
||||
categories={categories}
|
||||
expandedIds={expandedIds}
|
||||
variationCounts={variationCountsMap()}
|
||||
technicalFieldCounts={technicalFieldCountsMap()}
|
||||
onToggle={toggleExpanded}
|
||||
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
|
||||
onVariations={openVariations}
|
||||
onTechnicalForm={openTechnicalForm}
|
||||
onEdit={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) openEditModal(category)
|
||||
}}
|
||||
onTranslate={(id) => {
|
||||
if (!isMultiLanguageEnabled) return
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) setTranslateTarget(category)
|
||||
}}
|
||||
onRemove={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) setDeleteTarget(category)
|
||||
}}
|
||||
/>
|
||||
<div className={styles.listSection}>
|
||||
<div className={styles.searchWrap}>
|
||||
<Search size={20} className={styles.searchIcon} aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
className={styles.searchInput}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('categories.searchPlaceholder')}
|
||||
aria-label={t('categories.search')}
|
||||
/>
|
||||
{searchQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.searchClear}
|
||||
onClick={() => setSearchQuery('')}
|
||||
aria-label={t('categories.searchClear')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{filteredCategories.length === 0 ? (
|
||||
<div className={styles.list}>
|
||||
<p className={styles.empty}>{t('categories.searchEmpty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
<CategoryTree
|
||||
categories={filteredCategories}
|
||||
expandedIds={expandedIds}
|
||||
variationCounts={variationCountsMap()}
|
||||
technicalFieldCounts={technicalFieldCountsMap()}
|
||||
onToggle={toggleExpanded}
|
||||
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
|
||||
onVariations={openVariations}
|
||||
onTechnicalForm={openTechnicalForm}
|
||||
onEdit={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) openEditModal(category)
|
||||
}}
|
||||
onTranslate={(id) => {
|
||||
if (!isMultiLanguageEnabled) return
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) setTranslateTarget(category)
|
||||
}}
|
||||
onRemove={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) setDeleteTarget(category)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Pagination } from '../components/Pagination'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { ProductVariantsModal } from '../components/ProductVariantsModal'
|
||||
import { ProductTechnicalInfoModal } from '../components/ProductTechnicalInfoModal'
|
||||
import { ProductCommentsModal } from '../components/ProductCommentsModal'
|
||||
import { TranslationModal } from '../components/TranslationModal'
|
||||
import { TranslateAllLanguageModal } from '../components/TranslateAllLanguageModal'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
@@ -53,14 +52,12 @@ export function MyProductsPage() {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [variantCounts, setVariantCounts] = useState<Record<string, number>>({})
|
||||
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null)
|
||||
const [variantsTarget, setVariantsTarget] = useState<{
|
||||
id: string
|
||||
name: string
|
||||
categoryId: string
|
||||
} | null>(null)
|
||||
const [commentsTarget, setCommentsTarget] = useState<{ id: string; name: string } | null>(null)
|
||||
const [technicalTarget, setTechnicalTarget] = useState<{
|
||||
id: string
|
||||
name: string
|
||||
@@ -208,13 +205,6 @@ export function MyProductsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleComments(id: string) {
|
||||
const product = products.find((p) => p.id === id)
|
||||
if (product) {
|
||||
setCommentsTarget({ id, name: product.nameFa || product.nameEn })
|
||||
}
|
||||
}
|
||||
|
||||
function handleVariations(id: string) {
|
||||
const product = products.find((p) => p.id === id)
|
||||
if (product) {
|
||||
@@ -283,14 +273,6 @@ export function MyProductsPage() {
|
||||
writeListQuery('', 1)
|
||||
}
|
||||
|
||||
function handleCommentCountChange(count: number) {
|
||||
if (!commentsTarget) return
|
||||
setCommentCounts((prev) => ({
|
||||
...prev,
|
||||
[commentsTarget.id]: count,
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
@@ -374,12 +356,9 @@ export function MyProductsPage() {
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
commentCount={commentCounts[product.id] ?? product.commentCount}
|
||||
variantCount={variantCounts[product.id] ?? product.variantCount ?? 0}
|
||||
onEdit={handleEdit}
|
||||
onTranslate={handleTranslate}
|
||||
onQuickInfo={() => {}}
|
||||
onComments={handleComments}
|
||||
onTechnicalInfo={handleTechnicalInfo}
|
||||
onVariations={handleVariations}
|
||||
onRemove={handleRemoveRequest}
|
||||
@@ -435,16 +414,6 @@ export function MyProductsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{commentsTarget && (
|
||||
<ProductCommentsModal
|
||||
open={!!commentsTarget}
|
||||
productId={commentsTarget.id}
|
||||
productName={commentsTarget.name}
|
||||
onClose={() => setCommentsTarget(null)}
|
||||
onCountChange={handleCommentCountChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{technicalTarget && (
|
||||
<ProductTechnicalInfoModal
|
||||
open={!!technicalTarget}
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
.aiPromptField textarea.aiPromptTextarea,
|
||||
.aiPromptField textarea,
|
||||
textarea.aiPromptTextarea {
|
||||
min-height: 280px;
|
||||
resize: vertical;
|
||||
line-height: 1.55;
|
||||
border: 2px solid transparent;
|
||||
background-color: var(--surface, var(--elevated-surface));
|
||||
background-image:
|
||||
|
||||
@@ -49,3 +49,22 @@ export function getAncestorIds(categories: Category[], id: string): string[] {
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
/** Client-side filter by FA/EN name; keeps ancestors so the tree stays intact. */
|
||||
export function filterCategoriesByQuery(categories: Category[], query: string): Category[] {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
if (!normalized) return categories
|
||||
|
||||
const matchedIds = new Set<string>()
|
||||
|
||||
for (const category of categories) {
|
||||
const haystack = `${category.nameFa} ${category.nameEn}`.toLowerCase()
|
||||
if (!haystack.includes(normalized)) continue
|
||||
matchedIds.add(category.id)
|
||||
for (const ancestorId of getAncestorIds(categories, category.id)) {
|
||||
matchedIds.add(ancestorId)
|
||||
}
|
||||
}
|
||||
|
||||
return categories.filter((category) => matchedIds.has(category.id))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user