@@ -145,8 +169,8 @@ export function ImageCropper({
{t('products.form.zoom')}
setZoom(Number(e.target.value))}
diff --git a/apps/business/src/components/ProductCard.tsx b/apps/business/src/components/ProductCard.tsx
index b04e292..1358cc5 100644
--- a/apps/business/src/components/ProductCard.tsx
+++ b/apps/business/src/components/ProductCard.tsx
@@ -8,6 +8,9 @@ import {
ClipboardList,
ImageOff,
ExternalLink,
+ Info,
+ MessageSquare,
+ Search,
type LucideIcon,
} from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
@@ -22,14 +25,15 @@ 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
+ onSeo: (id: string) => void
onTechnicalInfo: (id: string) => void
onRemove: (id: string) => void
onVariations: (id: string) => void
@@ -47,9 +51,13 @@ type CardAction = {
export function ProductCard({
product,
+ commentCount,
variantCount,
onEdit,
onTranslate,
+ onQuickInfo,
+ onComments,
+ onSeo,
onTechnicalInfo,
onRemove,
onVariations,
@@ -76,7 +84,7 @@ export function ProductCard({
setImageFailed(false)
}, [imageSrc])
- const actions: CardAction[] = [
+ const primaryActions: CardAction[] = [
{
key: 'edit',
label: t('products.card.edit'),
@@ -116,6 +124,30 @@ export function ProductCard({
icon: ClipboardList,
onClick: () => onTechnicalInfo(product.id),
},
+ ]
+
+ const menuActions = [
+ {
+ key: 'seo',
+ label: t('products.card.seo'),
+ icon: Search,
+ onClick: () => onSeo(product.id),
+ },
+ {
+ key: 'quickInfo',
+ label: t('products.card.quickInfo'),
+ icon: Info,
+ onClick: () => onQuickInfo(product.id),
+ },
+ {
+ key: 'comments',
+ label:
+ commentCount > 0
+ ? `${t('products.card.comments')} (${commentCount})`
+ : t('products.card.comments'),
+ icon: MessageSquare,
+ onClick: () => onComments(product.id),
+ },
{
key: 'remove',
label: t('products.card.remove'),
@@ -125,20 +157,6 @@ export function ProductCard({
},
]
- 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 (
@@ -180,7 +198,7 @@ export function ProductCard({
- {visibleActions.map((action) => {
+ {primaryActions.map((action) => {
if (action.render) return
{action.render()}
const Icon = action.icon
@@ -188,7 +206,7 @@ export function ProductCard({
)
diff --git a/apps/business/src/components/ProductSeoModal.module.css b/apps/business/src/components/ProductSeoModal.module.css
new file mode 100644
index 0000000..ae8da99
--- /dev/null
+++ b/apps/business/src/components/ProductSeoModal.module.css
@@ -0,0 +1,9 @@
+.modal {
+ width: min(560px, calc(100vw - 32px));
+}
+
+.titleRow {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
diff --git a/apps/business/src/components/ProductSeoModal.tsx b/apps/business/src/components/ProductSeoModal.tsx
new file mode 100644
index 0000000..59c450d
--- /dev/null
+++ b/apps/business/src/components/ProductSeoModal.tsx
@@ -0,0 +1,170 @@
+import { useEffect, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { Search, X } from 'lucide-react'
+import { ApiError } from '../lib/api'
+import { useT } from '../i18n/useT'
+import { getProduct, updateProduct } from '../services/productService'
+import { seoMetaPayload } from '../utils/seoMeta'
+import { SeoMetaFields } from './SeoMetaFields'
+import styles from './VariationsModal.module.css'
+import seoStyles from './ProductSeoModal.module.css'
+
+interface ProductSeoModalProps {
+ open: boolean
+ productId: string
+ productName: string
+ onClose: () => void
+}
+
+const ANIMATION_MS = 220
+
+export function ProductSeoModal({
+ open,
+ productId,
+ productName,
+ onClose,
+}: ProductSeoModalProps) {
+ const t = useT()
+ const [mounted, setMounted] = useState(open)
+ const [closing, setClosing] = useState(false)
+ const [seoMetaTitle, setSeoMetaTitle] = useState('')
+ const [seoMetaDescription, setSeoMetaDescription] = useState('')
+ const [isLoading, setIsLoading] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ if (open) {
+ setMounted(true)
+ setClosing(false)
+ } else if (mounted) {
+ setClosing(true)
+ const timer = setTimeout(() => {
+ setMounted(false)
+ setClosing(false)
+ }, ANIMATION_MS)
+ return () => clearTimeout(timer)
+ }
+ }, [open, mounted])
+
+ useEffect(() => {
+ if (!open || !productId) return
+
+ const controller = new AbortController()
+ void loadData(controller.signal)
+ return () => controller.abort()
+ }, [open, productId])
+
+ async function loadData(signal?: AbortSignal) {
+ setIsLoading(true)
+ setError('')
+ try {
+ const product = await getProduct(productId, signal)
+ setSeoMetaTitle(product.seoMetaTitle ?? '')
+ setSeoMetaDescription(product.seoMetaDescription ?? '')
+ } catch (err) {
+ if (err instanceof DOMException && err.name === 'AbortError') return
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError(t('products.seo.errorLoad'))
+ }
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ async function handleSave() {
+ setIsSaving(true)
+ setError('')
+ try {
+ await updateProduct(productId, seoMetaPayload(seoMetaTitle, seoMetaDescription))
+ onClose()
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError(t('products.seo.errorSave'))
+ }
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ if (!mounted) return null
+
+ return createPortal(
+
!isSaving && onClose()}
+ >
+
e.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="product-seo-title"
+ >
+
+
+
+
+ {t('products.seo.title')}
+
+ {productName ?
{productName}
: null}
+
+
+
+
+
+ {isLoading ? (
+
{t('products.seo.loading')}
+ ) : (
+
+ )}
+
+ {error ?
{error}
: null}
+
+
+
+
+
+
+
+
,
+ document.body,
+ )
+}
diff --git a/apps/business/src/components/SearchableSelect.module.css b/apps/business/src/components/SearchableSelect.module.css
index 0b560b3..2461b4b 100644
--- a/apps/business/src/components/SearchableSelect.module.css
+++ b/apps/business/src/components/SearchableSelect.module.css
@@ -96,8 +96,8 @@
.dropdownInFlow {
position: static;
margin-top: 6px;
- max-height: none;
- overflow: visible;
+ max-height: min(280px, 40vh);
+ overflow-y: auto;
z-index: auto;
}
diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts
index cba636b..97f7b9d 100644
--- a/apps/business/src/i18n/messages.ts
+++ b/apps/business/src/i18n/messages.ts
@@ -553,12 +553,19 @@ const en = {
'products.card.edit': 'Edit product',
'products.card.translate': 'Translate',
'products.card.moreActions': 'More actions',
+ 'products.card.seo': 'SEO tools',
'products.card.quickInfo': 'Quick info',
'products.card.variations': 'Manage variations',
'products.card.comments': 'View comments',
'products.card.technical': 'Technical data',
'products.card.remove': 'Remove product',
'products.card.draft': 'Draft',
+ 'products.seo.title': 'SEO tools',
+ 'products.seo.loading': 'Loading SEO settings…',
+ 'products.seo.save': 'Save',
+ 'products.seo.saving': 'Saving…',
+ 'products.seo.errorLoad': 'Unable to load SEO settings.',
+ 'products.seo.errorSave': 'Unable to save SEO settings.',
'products.details.loading': 'Loading product...',
'products.details.notFound': 'Product not found.',
@@ -3119,12 +3126,19 @@ const fa: Record
= {
'products.card.edit': 'ویرایش محصول',
'products.card.translate': 'ترجمه',
'products.card.moreActions': 'عملیات بیشتر',
+ 'products.card.seo': 'ابزارهای SEO',
'products.card.quickInfo': 'اطلاعات سریع',
'products.card.variations': 'مدیریت تنوعها',
'products.card.comments': 'مشاهده نظرات',
'products.card.technical': 'اطلاعات فنی',
'products.card.remove': 'حذف محصول',
'products.card.draft': 'پیشنویس',
+ 'products.seo.title': 'ابزارهای SEO',
+ 'products.seo.loading': 'در حال بارگذاری تنظیمات SEO…',
+ 'products.seo.save': 'ذخیره',
+ 'products.seo.saving': 'در حال ذخیره…',
+ 'products.seo.errorLoad': 'بارگذاری تنظیمات SEO ممکن نشد.',
+ 'products.seo.errorSave': 'ذخیره تنظیمات SEO ممکن نشد.',
'products.details.loading': 'در حال بارگذاری محصول...',
'products.details.notFound': 'محصول یافت نشد.',
diff --git a/apps/business/src/pages/AddCustomerProductPage.tsx b/apps/business/src/pages/AddCustomerProductPage.tsx
index 5aed7b7..51f202c 100644
--- a/apps/business/src/pages/AddCustomerProductPage.tsx
+++ b/apps/business/src/pages/AddCustomerProductPage.tsx
@@ -751,6 +751,7 @@ export function AddCustomerProductPage() {
}
}}
aspect={1}
+ minZoom={0.4}
hint={t('customerProducts.form.images.thumbnailHint')}
/>
diff --git a/apps/business/src/pages/AddNewProductPage.tsx b/apps/business/src/pages/AddNewProductPage.tsx
index 33555e3..1f1380a 100644
--- a/apps/business/src/pages/AddNewProductPage.tsx
+++ b/apps/business/src/pages/AddNewProductPage.tsx
@@ -6,7 +6,6 @@ import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
-import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ProductFieldAiPromptModal } from '../components/ProductFieldAiPromptModal'
import { Tooltip } from '../components/Tooltip'
@@ -30,7 +29,6 @@ import {
} from '../services/productAiService'
import type { Category, FlatCategory } from '../types/category'
import { flattenCategories } from '../utils/categories'
-import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './AddNewProductPage.module.css'
@@ -54,8 +52,6 @@ export function AddNewProductPage() {
const [thumbnail, setThumbnail] = useState