Improve product AI UX, category search, and card actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-19 08:20:34 +03:30
co-authored by Cursor
parent d787a69195
commit b743c09e4d
15 changed files with 558 additions and 186 deletions
@@ -1,3 +1,7 @@
.modal {
width: min(560px, calc(100vw - 32px));
}
.headerBlock { .headerBlock {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -10,6 +14,20 @@
line-height: 1.45; 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 { .error {
margin: 0; margin: 0;
padding: 10px 12px; padding: 10px 12px;
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Sparkles, X } from 'lucide-react' import { Sparkles, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService' import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
import { createProductByAi, type ProductAiLanguage } from '../services/productAiService' import { createProductByAi, type ProductAiLanguage } from '../services/productAiService'
@@ -26,26 +28,29 @@ export function AddProductByAiModal({
onClose, onClose,
onCreated, onCreated,
}: AddProductByAiModalProps) { }: AddProductByAiModalProps) {
const t = useT()
const { locale } = useLocale()
const { showToast } = useToast() const { showToast } = useToast()
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [categoryId, setCategoryId] = useState('') const [categoryId, setCategoryId] = useState('')
const [name, setName] = useState('') const [prompt, setPrompt] = useState('')
const [language, setLanguage] = useState<ProductAiLanguage>('en') const [language, setLanguage] = useState<ProductAiLanguage>(locale === 'fa' ? 'fa' : 'en')
const [isLoadingCategories, setIsLoadingCategories] = useState(false) const [isLoadingCategories, setIsLoadingCategories] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const categoryOptions = useMemo(() => flattenCategories(categories), [categories]) const categoryOptions = useMemo(() => flattenCategories(categories), [categories])
const promptDir = language === 'fa' ? 'rtl' : 'ltr'
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setMounted(true) setMounted(true)
setClosing(false) setClosing(false)
setCategoryId('') setCategoryId('')
setName('') setPrompt('')
setLanguage('en') setLanguage(locale === 'fa' ? 'fa' : 'en')
setError('') setError('')
} else if (mounted) { } else if (mounted) {
setClosing(true) setClosing(true)
@@ -55,7 +60,7 @@ export function AddProductByAiModal({
}, ANIMATION_MS) }, ANIMATION_MS)
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
}, [open, mounted]) }, [open, mounted, locale])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@@ -86,7 +91,7 @@ export function AddProductByAiModal({
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load categories.') setError(t('products.ai.loadCategoriesError'))
} }
} finally { } finally {
setIsLoadingCategories(false) setIsLoadingCategories(false)
@@ -95,7 +100,7 @@ export function AddProductByAiModal({
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
if (!categoryId || !name.trim()) return if (!categoryId || !prompt.trim()) return
setIsSubmitting(true) setIsSubmitting(true)
setError('') setError('')
@@ -103,7 +108,7 @@ export function AddProductByAiModal({
try { try {
const result = await createProductByAi({ const result = await createProductByAi({
categoryId, categoryId,
name: name.trim(), name: prompt.trim(),
language, language,
}) })
showToast(result.message, 'success') showToast(result.message, 'success')
@@ -115,7 +120,7 @@ export function AddProductByAiModal({
} else if (err instanceof Error) { } else if (err instanceof Error) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to create product with AI.') setError(t('products.ai.createError'))
} }
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
@@ -131,7 +136,7 @@ export function AddProductByAiModal({
role="presentation" role="presentation"
> >
<div <div
className={`${modalStyles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`} className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="add-product-ai-title" aria-labelledby="add-product-ai-title"
@@ -139,19 +144,16 @@ export function AddProductByAiModal({
<div className={`${modalStyles.header} ${styles.header}`}> <div className={`${modalStyles.header} ${styles.header}`}>
<div className={styles.headerBlock}> <div className={styles.headerBlock}>
<h2 id="add-product-ai-title" className={modalStyles.title}> <h2 id="add-product-ai-title" className={modalStyles.title}>
Add Product by AI {t('products.ai.title')}
</h2> </h2>
<p className={styles.subtitle}> <p className={styles.subtitle}>{t('products.ai.subtitle')}</p>
AI will generate product content and technical data. You can add images and
variations afterward.
</p>
</div> </div>
<button <button
type="button" type="button"
className={modalStyles.closeBtn} className={modalStyles.closeBtn}
onClick={onClose} onClick={onClose}
disabled={isSubmitting} disabled={isSubmitting}
aria-label="Close" aria-label={t('common.close')}
> >
<X size={20} /> <X size={20} />
</button> </button>
@@ -159,43 +161,44 @@ export function AddProductByAiModal({
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}> <form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
<div className={modalStyles.field}> <div className={modalStyles.field}>
<label>Category</label> <label htmlFor="ai-product-category">{t('products.ai.category')}</label>
<SearchableSelect <SearchableSelect
options={categoryOptions} options={categoryOptions}
value={categoryId} value={categoryId}
onChange={setCategoryId} onChange={setCategoryId}
placeholder="Search and select category..." placeholder={t('products.ai.categoryPlaceholder')}
allowEmpty={false} allowEmpty={false}
dropdownVariant="inFlow" dropdownVariant="inFlow"
/> />
</div> </div>
<div className={modalStyles.field}> <div className={modalStyles.field}>
<label htmlFor="ai-product-name">Product name</label> <label htmlFor="ai-product-prompt">{t('products.ai.prompt')}</label>
<input <textarea
id="ai-product-name" id="ai-product-prompt"
value={name} value={prompt}
onChange={(e) => setName(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
placeholder={ placeholder={t('products.ai.promptPlaceholder')}
language === 'fa' ? 'نام محصول را وارد کنید' : 'Enter product name' dir={promptDir}
} className={`${styles.prompt}${language === 'fa' ? ' faText' : ''}`}
dir={language === 'fa' ? 'rtl' : 'ltr'}
className={language === 'fa' ? 'faText' : undefined}
disabled={isSubmitting} disabled={isSubmitting}
rows={5}
required required
minLength={2}
/> />
<p className={styles.promptHint}>{t('products.ai.promptHint')}</p>
</div> </div>
<div className={`${modalStyles.field} ${fieldStyles.field}`}> <div className={`${modalStyles.field} ${fieldStyles.field}`}>
<label htmlFor="ai-product-language">Language</label> <label htmlFor="ai-product-language">{t('products.ai.language')}</label>
<select <select
id="ai-product-language" id="ai-product-language"
value={language} value={language}
onChange={(e) => setLanguage(e.target.value as ProductAiLanguage)} onChange={(e) => setLanguage(e.target.value as ProductAiLanguage)}
disabled={isSubmitting} disabled={isSubmitting}
> >
<option value="en">English</option> <option value="en">{t('products.ai.language.en')}</option>
<option value="fa">Persian (Farsi)</option> <option value="fa">{t('products.ai.language.fa')}</option>
</select> </select>
</div> </div>
@@ -208,24 +211,24 @@ export function AddProductByAiModal({
onClick={onClose} onClick={onClose}
disabled={isSubmitting} disabled={isSubmitting}
> >
Cancel {t('common.cancel')}
</button> </button>
<button <button
type="submit" type="submit"
className={aiStyles.aiBtn} className={aiStyles.aiBtn}
disabled={ disabled={
isSubmitting || isLoadingCategories || !categoryId || !name.trim() isSubmitting || isLoadingCategories || !categoryId || !prompt.trim()
} }
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Sparkles size={16} /> <Sparkles size={16} />
Creating... {t('products.ai.creating')}
</> </>
) : ( ) : (
<> <>
<Sparkles size={16} /> <Sparkles size={16} />
Create product {t('products.ai.create')}
</> </>
)} )}
</button> </button>
@@ -1,5 +1,6 @@
.modal { .modal {
max-width: 560px; width: min(820px, calc(100vw - 32px));
max-width: 820px;
} }
.headerBlock { .headerBlock {
@@ -12,7 +12,11 @@ export function buildCategoryAiPrompt(locale: 'fa' | 'en') {
if (locale === 'fa') { if (locale === 'fa') {
return `یک درخت دسته‌بندی محصول کاربردی برای فروشگاه من بساز. return `یک درخت دسته‌بندی محصول کاربردی برای فروشگاه من بساز.
نام انگلیسی، نام فارسی با خط پارسی، و توضیح کوتاه انگلیسی را برای هر دسته اضافه کن. برای هر دسته این موارد را مشخص کن:
- نام انگلیسی
- نام فارسی با خط پارسی
- توضیح کوتاه فارسی
۳ تا ۵ دسته اصلی با زیردسته‌های مرتبط بساز تا مرور برای خریداران راحت‌تر شود.` ۳ تا ۵ دسته اصلی با زیردسته‌های مرتبط بساز تا مرور برای خریداران راحت‌تر شود.`
} }
@@ -136,7 +140,7 @@ export function CategoryAiPromptModal({
className={aiStyles.aiPromptTextarea} className={aiStyles.aiPromptTextarea}
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
rows={8} rows={14}
disabled={isRunning} disabled={isRunning}
required required
lang={isFa ? 'fa' : 'en'} lang={isFa ? 'fa' : 'en'}
@@ -202,7 +202,8 @@
.variantBadge, .variantBadge,
.commentBadge, .commentBadge,
.translateBadge { .translateBadge,
.actionBadge {
position: absolute; position: absolute;
top: -4px; top: -4px;
right: -4px; right: -4px;
@@ -241,3 +242,12 @@
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
color: #ef4444; color: #ef4444;
} }
.menuItemDanger {
color: #ef4444;
}
.menuItemDanger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
+100 -78
View File
@@ -1,14 +1,14 @@
import { useEffect, useState } from 'react' import { useEffect, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
Pencil, Pencil,
Globe, Globe,
Info,
Trash2, Trash2,
Layers, Layers,
MessageSquare,
ClipboardList, ClipboardList,
ImageOff, ImageOff,
ExternalLink,
type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
import type { Product } from '../types/product' import type { Product } from '../types/product'
@@ -19,29 +19,37 @@ import { hasBusinessModule } from '../utils/businessModules'
import { formatProductCategoryChip } from '../services/productService' import { formatProductCategoryChip } from '../services/productService'
import { buildWebsiteContentUrl } from '../utils/websiteLinks' import { buildWebsiteContentUrl } from '../utils/websiteLinks'
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl' import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
import { ProductCardOverflowMenu } from './ProductCardOverflowMenu'
import styles from './ProductCard.module.css' 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 { interface ProductCardProps {
product: Product product: Product
commentCount: number
variantCount: number variantCount: number
onEdit: (id: string) => void onEdit: (id: string) => void
onTranslate: (id: string) => void onTranslate: (id: string) => void
onQuickInfo: (id: string) => void
onComments: (id: string) => void
onTechnicalInfo: (id: string) => void onTechnicalInfo: (id: string) => void
onRemove: (id: string) => void onRemove: (id: string) => void
onVariations: (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({ export function ProductCard({
product, product,
commentCount,
variantCount, variantCount,
onEdit, onEdit,
onTranslate, onTranslate,
onQuickInfo,
onComments,
onTechnicalInfo, onTechnicalInfo,
onRemove, onRemove,
onVariations, onVariations,
@@ -68,6 +76,69 @@ export function ProductCard({
setImageFailed(false) setImageFailed(false)
}, [imageSrc]) }, [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 ( return (
<article className={styles.card}> <article className={styles.card}>
<Link to={`/products/detail/${product.id}`} className={styles.clickable}> <Link to={`/products/detail/${product.id}`} className={styles.clickable}>
@@ -109,83 +180,34 @@ export function ProductCard({
</Link> </Link>
<div className={styles.controls}> <div className={styles.controls}>
<Tooltip label={t('products.card.edit')}> {visibleActions.map((action) => {
<button type="button" onClick={() => onEdit(product.id)} aria-label={t('products.card.edit')}> if (action.render) return <span key={action.key}>{action.render()}</span>
<Pencil size={16} />
</button> const Icon = action.icon
</Tooltip> return (
<OpenOnWebsiteControl href={websiteUrl} /> <Tooltip key={action.key} label={action.label}>
{showTranslationControls ? (
<Tooltip label={t('products.card.translate')}>
<button <button
type="button" type="button"
className={styles.iconBtn} className={`${styles.iconBtn}${action.danger ? ` ${styles.danger}` : ''}`}
onClick={() => onTranslate(product.id)} onClick={action.onClick}
aria-label={ aria-label={
translationCount > 0 action.badge != null ? `${action.label} (${action.badge})` : action.label
? `${t('products.card.translate')} (${translationCount})`
: t('products.card.translate')
} }
> >
<Globe size={16} /> <Icon size={16} />
{translationCount > 0 && ( {action.badge != null ? (
<span className={styles.translateBadge}>{translationCount}</span> <span className={styles.actionBadge}>{action.badge}</span>
)}
</button>
</Tooltip>
) : null} ) : 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> </button>
</Tooltip> </Tooltip>
)
})}
{overflowActions.length > 0 ? (
<ProductCardOverflowMenu
productName={primaryName}
items={overflowActions}
/>
) : null}
</div> </div>
</article> </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} className={aiStyles.aiPromptTextarea}
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
rows={10} rows={14}
disabled={isRunning} disabled={isRunning}
required required
lang={isFa ? 'fa' : 'en'} lang={isFa ? 'fa' : 'en'}
@@ -15,12 +15,13 @@ export function buildTechnicalFormAiPrompt(
return `برای دسته‌بندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده. return `برای دسته‌بندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده.
برای هر فیلد این موارد را مشخص کن: برای هر فیلد این موارد را مشخص کن:
- برچسب کوتاه انگلیسی (مثلاً Screen Size، Material، Color) - برچسب کوتاه فارسی (مثلاً سایز صفحه نمایش، جنس بدنه، رنگ‌ها)
- نوع ورودی: text، textarea، select یا multi_select - نوع ورودی: text، textarea، select یا multi_select
- الزامی بودن یا نبودن - الزامی بودن یا نبودن
- برای select / multi_select: فهرست گزینه‌ها - برای select / multi_select: فهرست گزینه‌ها به فارسی
روی مشخصات و ویژگی‌هایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی می‌کنند.` روی مشخصات و ویژگی‌هایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی می‌کنند.
برچسب‌ها و گزینه‌ها را به فارسی بنویس.`
} }
return `Suggest technical data form fields for the "${categoryName}" product category. return `Suggest technical data form fields for the "${categoryName}" product category.
@@ -147,7 +148,7 @@ export function TechnicalFormAiPromptModal({
className={aiStyles.aiPromptTextarea} className={aiStyles.aiPromptTextarea}
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
rows={8} rows={14}
disabled={isRunning} disabled={isRunning}
required required
lang={isFa ? 'fa' : 'en'} lang={isFa ? 'fa' : 'en'}
+44
View File
@@ -350,6 +350,23 @@ const en = {
'products.list.deleteMessage': 'products.list.deleteMessage':
'Are you sure you want to delete "{name}"? This action cannot be undone.', 'Are you sure you want to delete "{name}"? This action cannot be undone.',
'products.list.addByAi': 'Add product by AI', '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.aiMenu': 'Artificial Intelligence',
'products.list.translateAll': 'Translate all products', 'products.list.translateAll': 'Translate all products',
'products.list.translateAll.pickTitle': 'Translate all products', 'products.list.translateAll.pickTitle': 'Translate all products',
@@ -535,6 +552,7 @@ const en = {
'products.card.edit': 'Edit product', 'products.card.edit': 'Edit product',
'products.card.translate': 'Translate', 'products.card.translate': 'Translate',
'products.card.moreActions': 'More actions',
'products.card.quickInfo': 'Quick info', 'products.card.quickInfo': 'Quick info',
'products.card.variations': 'Manage variations', 'products.card.variations': 'Manage variations',
'products.card.comments': 'View comments', 'products.card.comments': 'View comments',
@@ -631,6 +649,10 @@ const en = {
'categories.page.subtitle': 'Manage your product categories and subcategories.', 'categories.page.subtitle': 'Manage your product categories and subcategories.',
'categories.loading': 'Loading categories...', 'categories.loading': 'Loading categories...',
'categories.empty': 'You have not added any categories yet.', '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.add': 'Add Category',
'categories.addSub': 'Add Sub Category', 'categories.addSub': 'Add Sub Category',
'categories.edit': 'Edit Category', 'categories.edit': 'Edit Category',
@@ -2894,6 +2916,23 @@ const fa: Record<MessageKey, string> = {
'products.list.deleteMessage': 'products.list.deleteMessage':
'آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.', 'آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.',
'products.list.addByAi': 'افزودن محصول با هوش مصنوعی', '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.aiMenu': 'هوش مصنوعی',
'products.list.translateAll': 'ترجمه همه محصولات', 'products.list.translateAll': 'ترجمه همه محصولات',
'products.list.translateAll.pickTitle': 'ترجمه همه محصولات', 'products.list.translateAll.pickTitle': 'ترجمه همه محصولات',
@@ -3079,6 +3118,7 @@ const fa: Record<MessageKey, string> = {
'products.card.edit': 'ویرایش محصول', 'products.card.edit': 'ویرایش محصول',
'products.card.translate': 'ترجمه', 'products.card.translate': 'ترجمه',
'products.card.moreActions': 'عملیات بیشتر',
'products.card.quickInfo': 'اطلاعات سریع', 'products.card.quickInfo': 'اطلاعات سریع',
'products.card.variations': 'مدیریت تنوع‌ها', 'products.card.variations': 'مدیریت تنوع‌ها',
'products.card.comments': 'مشاهده نظرات', 'products.card.comments': 'مشاهده نظرات',
@@ -3173,6 +3213,10 @@ const fa: Record<MessageKey, string> = {
'categories.page.subtitle': 'دسته‌بندی‌ها و زیردسته‌های محصولات خود را مدیریت کنید.', 'categories.page.subtitle': 'دسته‌بندی‌ها و زیردسته‌های محصولات خود را مدیریت کنید.',
'categories.loading': 'در حال بارگذاری دسته‌بندی‌ها...', 'categories.loading': 'در حال بارگذاری دسته‌بندی‌ها...',
'categories.empty': 'هنوز دسته‌بندی‌ای ثبت نکرده‌اید.', 'categories.empty': 'هنوز دسته‌بندی‌ای ثبت نکرده‌اید.',
'categories.search': 'جستجوی دسته‌بندی‌ها',
'categories.searchPlaceholder': 'جستجوی دسته‌بندی‌ها…',
'categories.searchClear': 'پاک کردن جستجو',
'categories.searchEmpty': 'دسته‌بندی‌ای با این جستجو پیدا نشد.',
'categories.add': 'افزودن دسته‌بندی', 'categories.add': 'افزودن دسته‌بندی',
'categories.addSub': 'افزودن زیردسته', 'categories.addSub': 'افزودن زیردسته',
'categories.edit': 'ویرایش دسته‌بندی', '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 { .list {
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
+61 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { FolderTree, Globe, Plus, Sparkles } from 'lucide-react' import { FolderTree, Globe, Plus, Search, Sparkles, X } from 'lucide-react'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { CategoryModal } from '../components/CategoryModal' import { CategoryModal } from '../components/CategoryModal'
import { CategoryAiPromptModal } from '../components/CategoryAiPromptModal' import { CategoryAiPromptModal } from '../components/CategoryAiPromptModal'
@@ -38,6 +38,7 @@ import {
toCategoryFormPayload, toCategoryFormPayload,
updateProductCategory, updateProductCategory,
} from '../services/productCategoryService' } from '../services/productCategoryService'
import { filterCategoriesByQuery, getAncestorIds } from '../utils/categories'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { translateAllProductCategories } from '../services/translationService' import { translateAllProductCategories } from '../services/translationService'
import type { TranslationLocale } from '../types/translation' import type { TranslationLocale } from '../types/translation'
@@ -52,6 +53,7 @@ export function CategoriesPage() {
const { enabledModules } = useTenantBranding() const { enabledModules } = useTenantBranding()
const isMultiLanguageEnabled = hasBusinessModule(enabledModules, 'multilanguage_data') const isMultiLanguageEnabled = hasBusinessModule(enabledModules, 'multilanguage_data')
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set()) const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [defaultParentId, setDefaultParentId] = useState('') 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() { function openAiFabMenu() {
if (aiMenuCloseTimerRef.current !== null) { if (aiMenuCloseTimerRef.current !== null) {
window.clearTimeout(aiMenuCloseTimerRef.current) window.clearTimeout(aiMenuCloseTimerRef.current)
@@ -548,10 +576,38 @@ export function CategoriesPage() {
actionLabel={t('categories.add')} actionLabel={t('categories.add')}
onAction={() => openCreateModal()} onAction={() => openCreateModal()}
/> />
) : (
<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}> <div className={styles.list}>
<CategoryTree <CategoryTree
categories={categories} categories={filteredCategories}
expandedIds={expandedIds} expandedIds={expandedIds}
variationCounts={variationCountsMap()} variationCounts={variationCountsMap()}
technicalFieldCounts={technicalFieldCountsMap()} technicalFieldCounts={technicalFieldCountsMap()}
@@ -575,6 +631,8 @@ export function CategoriesPage() {
/> />
</div> </div>
)} )}
</div>
)}
<CategoryModal <CategoryModal
open={modalOpen} open={modalOpen}
@@ -8,7 +8,6 @@ import { Pagination } from '../components/Pagination'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { ProductVariantsModal } from '../components/ProductVariantsModal' import { ProductVariantsModal } from '../components/ProductVariantsModal'
import { ProductTechnicalInfoModal } from '../components/ProductTechnicalInfoModal' import { ProductTechnicalInfoModal } from '../components/ProductTechnicalInfoModal'
import { ProductCommentsModal } from '../components/ProductCommentsModal'
import { TranslationModal } from '../components/TranslationModal' import { TranslationModal } from '../components/TranslationModal'
import { TranslateAllLanguageModal } from '../components/TranslateAllLanguageModal' import { TranslateAllLanguageModal } from '../components/TranslateAllLanguageModal'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
@@ -53,14 +52,12 @@ export function MyProductsPage() {
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [variantCounts, setVariantCounts] = useState<Record<string, number>>({}) 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 [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null)
const [variantsTarget, setVariantsTarget] = useState<{ const [variantsTarget, setVariantsTarget] = useState<{
id: string id: string
name: string name: string
categoryId: string categoryId: string
} | null>(null) } | null>(null)
const [commentsTarget, setCommentsTarget] = useState<{ id: string; name: string } | null>(null)
const [technicalTarget, setTechnicalTarget] = useState<{ const [technicalTarget, setTechnicalTarget] = useState<{
id: string id: string
name: 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) { function handleVariations(id: string) {
const product = products.find((p) => p.id === id) const product = products.find((p) => p.id === id)
if (product) { if (product) {
@@ -283,14 +273,6 @@ export function MyProductsPage() {
writeListQuery('', 1) writeListQuery('', 1)
} }
function handleCommentCountChange(count: number) {
if (!commentsTarget) return
setCommentCounts((prev) => ({
...prev,
[commentsTarget.id]: count,
}))
}
return ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
<Breadcrumbs <Breadcrumbs
@@ -374,12 +356,9 @@ export function MyProductsPage() {
<ProductCard <ProductCard
key={product.id} key={product.id}
product={product} product={product}
commentCount={commentCounts[product.id] ?? product.commentCount}
variantCount={variantCounts[product.id] ?? product.variantCount ?? 0} variantCount={variantCounts[product.id] ?? product.variantCount ?? 0}
onEdit={handleEdit} onEdit={handleEdit}
onTranslate={handleTranslate} onTranslate={handleTranslate}
onQuickInfo={() => {}}
onComments={handleComments}
onTechnicalInfo={handleTechnicalInfo} onTechnicalInfo={handleTechnicalInfo}
onVariations={handleVariations} onVariations={handleVariations}
onRemove={handleRemoveRequest} 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 && ( {technicalTarget && (
<ProductTechnicalInfoModal <ProductTechnicalInfoModal
open={!!technicalTarget} open={!!technicalTarget}
+3
View File
@@ -89,6 +89,9 @@
.aiPromptField textarea.aiPromptTextarea, .aiPromptField textarea.aiPromptTextarea,
.aiPromptField textarea, .aiPromptField textarea,
textarea.aiPromptTextarea { textarea.aiPromptTextarea {
min-height: 280px;
resize: vertical;
line-height: 1.55;
border: 2px solid transparent; border: 2px solid transparent;
background-color: var(--surface, var(--elevated-surface)); background-color: var(--surface, var(--elevated-surface));
background-image: background-image:
+19
View File
@@ -49,3 +49,22 @@ export function getAncestorIds(categories: Category[], id: string): string[] {
return ids 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))
}