Add multilanguage catalog translations UI for products and categories.

Include AI translate-all with locale picker, count badges, MultiLanguage Data module gating, and shared modal action layout polish.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-04 09:51:11 +03:30
co-authored by Cursor
parent 8c187b18be
commit 2a32c46b2e
43 changed files with 1624 additions and 78 deletions
+182 -9
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { Package, Plus, RotateCcw, Search, Sparkles } from 'lucide-react'
import { Globe, Package, Plus, RotateCcw, Search, Sparkles } from 'lucide-react'
import { EmptyState, ListEmptyState } from '../components/EmptyState'
import { ProductCard } from '../components/ProductCard'
import { AddProductByAiModal } from '../components/AddProductByAiModal'
@@ -9,16 +9,23 @@ 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'
import { PageTitle } from '../components/PageTitle'
import { useToast } from '../context/ToastContext'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { translateAllProducts } from '../services/translationService'
import type { TranslationLocale } from '../types/translation'
import {
deleteProduct,
listProducts,
PRODUCTS_PER_PAGE,
} from '../services/productService'
import type { Product } from '../types/product'
import { hasBusinessModule } from '../utils/businessModules'
import pageStyles from '../components/PageContent.module.css'
import filterStyles from '../components/ListFiltersPanel.module.css'
import styles from './MyProductsPage.module.css'
@@ -34,6 +41,9 @@ function readListQuery(searchParams: URLSearchParams) {
export function MyProductsPage() {
const t = useT()
const navigate = useNavigate()
const { showToast } = useToast()
const { enabledModules } = useTenantBranding()
const isMultiLanguageEnabled = hasBusinessModule(enabledModules, 'multilanguage_data')
const [searchParams, setSearchParams] = useSearchParams()
const { name: appliedName, page: currentPage } = readListQuery(searchParams)
@@ -57,6 +67,12 @@ export function MyProductsPage() {
categoryId: string
} | null>(null)
const [aiModalOpen, setAiModalOpen] = useState(false)
const [translatingAll, setTranslatingAll] = useState(false)
const [translateAllOpen, setTranslateAllOpen] = useState(false)
const [aiMenuOpen, setAiMenuOpen] = useState(false)
const aiFabGroupRef = useRef<HTMLDivElement>(null)
const aiMenuCloseTimerRef = useRef<number | null>(null)
const [translateTarget, setTranslateTarget] = useState<Product | null>(null)
const [draftName, setDraftName] = useState(appliedName)
const totalPages = Math.max(1, Math.ceil(totalProducts / PRODUCTS_PER_PAGE))
@@ -71,6 +87,47 @@ export function MyProductsPage() {
return () => controller.abort()
}, [currentPage, appliedName])
useEffect(() => {
if (!isMultiLanguageEnabled) setTranslateTarget(null)
}, [isMultiLanguageEnabled])
useEffect(() => {
if (!aiMenuOpen) return
const onPointerDown = (event: PointerEvent) => {
if (!aiFabGroupRef.current?.contains(event.target as Node)) {
setAiMenuOpen(false)
}
}
document.addEventListener('pointerdown', onPointerDown)
return () => document.removeEventListener('pointerdown', onPointerDown)
}, [aiMenuOpen])
useEffect(() => {
return () => {
if (aiMenuCloseTimerRef.current !== null) {
window.clearTimeout(aiMenuCloseTimerRef.current)
}
}
}, [])
function openAiFabMenu() {
if (aiMenuCloseTimerRef.current !== null) {
window.clearTimeout(aiMenuCloseTimerRef.current)
aiMenuCloseTimerRef.current = null
}
setAiMenuOpen(true)
}
function scheduleCloseAiFabMenu() {
if (aiMenuCloseTimerRef.current !== null) {
window.clearTimeout(aiMenuCloseTimerRef.current)
}
aiMenuCloseTimerRef.current = window.setTimeout(() => {
setAiMenuOpen(false)
aiMenuCloseTimerRef.current = null
}, 160)
}
function writeListQuery(name: string, page: number) {
const next = new URLSearchParams()
if (name) next.set('q', name)
@@ -109,6 +166,48 @@ export function MyProductsPage() {
navigate(`/products/edit/${id}`)
}
function handleTranslate(id: string) {
if (!isMultiLanguageEnabled) return
const product = products.find((p) => p.id === id)
if (product) setTranslateTarget(product)
}
async function handleTranslateAllProducts(locale: TranslationLocale) {
if (translatingAll || products.length === 0) return
setTranslatingAll(true)
setError('')
try {
const result = await translateAllProducts(locale)
await loadProducts(currentPage)
const langLabel = t(locale === 'en' ? 'translations.lang.en' : 'translations.lang.ar')
if (result.translated === 0 && result.failed > 0) {
showToast(t('products.list.translateAll.error'), 'error')
} else if (result.translated === 0) {
showToast(t('products.list.translateAll.none'), 'info')
} else {
showToast(
t('products.list.translateAll.done', {
translated: result.translated,
lang: langLabel,
}),
'success',
)
}
setTranslateAllOpen(false)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('products.list.translateAll.error'))
}
} finally {
setTranslatingAll(false)
}
}
function handleComments(id: string) {
const product = products.find((p) => p.id === id)
if (product) {
@@ -278,6 +377,7 @@ export function MyProductsPage() {
commentCount={commentCounts[product.id] ?? product.commentCount}
variantCount={variantCounts[product.id] ?? product.variantCount ?? 0}
onEdit={handleEdit}
onTranslate={handleTranslate}
onQuickInfo={() => {}}
onComments={handleComments}
onTechnicalInfo={handleTechnicalInfo}
@@ -361,15 +461,88 @@ export function MyProductsPage() {
onCreated={(productId) => navigate(`/products/edit/${productId}`)}
/>
{translateTarget && (
<TranslationModal
open={!!translateTarget}
entityType="product"
entityId={translateTarget.id}
entityName={translateTarget.nameFa || translateTarget.nameEn}
onClose={() => setTranslateTarget(null)}
onChanged={(count) => {
const targetId = translateTarget.id
setProducts((prev) =>
prev.map((product) =>
product.id === targetId
? { ...product, translationCount: count }
: product,
),
)
}}
/>
)}
<TranslateAllLanguageModal
open={translateAllOpen}
title={t('products.list.translateAll.pickTitle')}
subtitle={t('products.list.translateAll.pickSubtitle')}
running={translatingAll}
runLabel={t('products.list.translateAll.run')}
runningLabel={t('products.list.translateAll.running')}
onClose={() => !translatingAll && setTranslateAllOpen(false)}
onConfirm={(locale) => void handleTranslateAllProducts(locale)}
/>
<div className={styles.fabDock}>
<button
type="button"
className={aiStyles.aiFabStrip}
onClick={() => setAiModalOpen(true)}
<div
ref={aiFabGroupRef}
className={aiStyles.aiFabGroup}
data-open={aiMenuOpen ? 'true' : undefined}
onMouseEnter={openAiFabMenu}
onMouseLeave={scheduleCloseAiFabMenu}
>
<Sparkles size={18} />
<span>{t('products.list.addByAi')}</span>
</button>
<div className={aiStyles.aiFabMenu} role="menu">
<button
type="button"
role="menuitem"
className={aiStyles.aiFabMenuItem}
onClick={() => {
setAiMenuOpen(false)
setAiModalOpen(true)
}}
disabled={translatingAll}
>
<Sparkles size={16} />
<span>{t('products.list.addByAi')}</span>
</button>
{isMultiLanguageEnabled ? (
<button
type="button"
role="menuitem"
className={aiStyles.aiFabMenuItem}
onClick={() => {
setAiMenuOpen(false)
setTranslateAllOpen(true)
}}
disabled={translatingAll || products.length === 0}
>
<Globe size={16} />
<span>{t('products.list.translateAll')}</span>
</button>
) : null}
</div>
<button
type="button"
className={aiStyles.aiFabStrip}
aria-expanded={aiMenuOpen}
aria-haspopup="menu"
aria-label={t('products.list.aiMenu')}
onClick={() => setAiMenuOpen((open) => !open)}
disabled={translatingAll}
>
<Sparkles size={18} />
<span>{t('products.list.aiMenu')}</span>
</button>
</div>
<Link to="/products/new" className={styles.addFab} aria-label={t('products.list.addNew')}>
<Plus size={24} />
</Link>