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
@@ -515,6 +515,8 @@
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid rgba(148, 163, 184, 0.25);
/* Primary/next (last child) stays on the physical left */
direction: rtl;
}
.ghostBtn,
@@ -201,6 +201,8 @@
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
/* Main/submit button (last child) stays on the physical left */
direction: rtl;
}
.cancelBtn {
@@ -185,6 +185,8 @@
.actions {
display: flex;
justify-content: flex-end;
/* Main/save button stays on the physical left */
direction: rtl;
}
.saveBtn {
+182 -10
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react'
import { FolderTree, Plus, Sparkles } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { FolderTree, Globe, Plus, Sparkles } from 'lucide-react'
import { useToast } from '../context/ToastContext'
import { CategoryModal } from '../components/CategoryModal'
import { CategoryAiPromptModal } from '../components/CategoryAiPromptModal'
import { CategoryTree } from '../components/CategoryTree'
import { TranslationModal } from '../components/TranslationModal'
import { TranslateAllLanguageModal } from '../components/TranslateAllLanguageModal'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { EmptyState } from '../components/EmptyState'
import { PageTitle } from '../components/PageTitle'
@@ -14,6 +16,7 @@ import { TechnicalFormAiPromptModal } from '../components/TechnicalFormAiPromptM
import type { Category, CategoryFormData } from '../types/category'
import type { Variation } from '../types/variation'
import type { TechnicalFormFieldDraft } from '../types/technicalForm'
import { useTenantBranding } from '../context/TenantBrandingContext'
import {
listCategoryVariations,
saveCategoryVariations,
@@ -36,6 +39,9 @@ import {
updateProductCategory,
} from '../services/productCategoryService'
import { useT } from '../i18n/useT'
import { translateAllProductCategories } from '../services/translationService'
import type { TranslationLocale } from '../types/translation'
import { hasBusinessModule } from '../utils/businessModules'
import pageStyles from '../components/PageContent.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './CategoriesPage.module.css'
@@ -43,6 +49,8 @@ import styles from './CategoriesPage.module.css'
export function CategoriesPage() {
const t = useT()
const { showToast } = useToast()
const { enabledModules } = useTenantBranding()
const isMultiLanguageEnabled = hasBusinessModule(enabledModules, 'multilanguage_data')
const [categories, setCategories] = useState<Category[]>([])
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [modalOpen, setModalOpen] = useState(false)
@@ -74,7 +82,13 @@ export function CategoriesPage() {
const [technicalError, setTechnicalError] = useState('')
const [aiModalOpen, setAiModalOpen] = useState(false)
const [aiGenerating, setAiGenerating] = 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 [techAiPromptOpen, setTechAiPromptOpen] = useState(false)
const [translateTarget, setTranslateTarget] = useState<Category | null>(null)
useEffect(() => {
const controller = new AbortController()
@@ -82,6 +96,47 @@ export function CategoriesPage() {
return () => controller.abort()
}, [])
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)
}
async function loadCategories(signal?: AbortSignal) {
setIsLoading(true)
setError('')
@@ -141,7 +196,11 @@ export function CategoriesPage() {
setCategories((prev) =>
prev.map((category) =>
category.id === editingCategory.id
? mapProductCategoryToUi(result.category)
? {
...mapProductCategoryToUi(result.category),
variationCount: category.variationCount,
translationCount: category.translationCount ?? 0,
}
: category,
),
)
@@ -391,6 +450,43 @@ export function CategoriesPage() {
}
}
async function handleTranslateAllCategories(locale: TranslationLocale) {
if (translatingAll || categories.length === 0) return
setTranslatingAll(true)
setError('')
try {
const result = await translateAllProductCategories(locale)
const items = await listProductCategories()
setCategories(items.map(mapProductCategoryToUi))
const langLabel = t(locale === 'en' ? 'translations.lang.en' : 'translations.lang.ar')
if (result.translated === 0 && result.failed > 0) {
showToast(t('categories.translateAll.error'), 'error')
} else if (result.translated === 0) {
showToast(t('categories.translateAll.none'), 'info')
} else {
showToast(
t('categories.translateAll.done', {
translated: result.translated,
lang: langLabel,
}),
'success',
)
}
setTranslateAllOpen(false)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('categories.translateAll.error'))
}
} finally {
setTranslatingAll(false)
}
}
async function handleSaveVariations() {
if (!variationsTarget) return
@@ -467,6 +563,11 @@ export function CategoriesPage() {
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)
@@ -570,15 +671,86 @@ export function CategoriesPage() {
isRunning={aiGenerating}
/>
<TranslateAllLanguageModal
open={translateAllOpen}
title={t('categories.translateAll.pickTitle')}
subtitle={t('categories.translateAll.pickSubtitle')}
running={translatingAll}
onClose={() => !translatingAll && setTranslateAllOpen(false)}
onConfirm={(locale) => void handleTranslateAllCategories(locale)}
/>
{translateTarget && (
<TranslationModal
open={!!translateTarget}
entityType="category"
entityId={translateTarget.id}
entityName={translateTarget.nameFa || translateTarget.nameEn}
onClose={() => setTranslateTarget(null)}
onChanged={(count) => {
const targetId = translateTarget.id
setCategories((prev) =>
prev.map((category) =>
category.id === targetId
? { ...category, translationCount: count }
: category,
),
)
}}
/>
)}
<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('categories.addByAi')}</span>
</button>
<div className={aiStyles.aiFabMenu} role="menu">
<button
type="button"
role="menuitem"
className={aiStyles.aiFabMenuItem}
onClick={() => {
setAiMenuOpen(false)
setAiModalOpen(true)
}}
disabled={translatingAll || aiGenerating}
>
<Sparkles size={16} />
<span>{t('categories.addByAi')}</span>
</button>
{isMultiLanguageEnabled ? (
<button
type="button"
role="menuitem"
className={aiStyles.aiFabMenuItem}
onClick={() => {
setAiMenuOpen(false)
setTranslateAllOpen(true)
}}
disabled={translatingAll || categories.length === 0}
>
<Globe size={16} />
<span>{t('categories.translateAll')}</span>
</button>
) : null}
</div>
<button
type="button"
className={aiStyles.aiFabStrip}
aria-expanded={aiMenuOpen}
aria-haspopup="menu"
aria-label={t('categories.aiMenu')}
onClick={() => setAiMenuOpen((open) => !open)}
disabled={translatingAll || aiGenerating}
>
<Sparkles size={18} />
<span>{t('categories.aiMenu')}</span>
</button>
</div>
<button
type="button"
className={styles.addFab}
+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>
@@ -99,6 +99,8 @@
.actions {
display: flex;
justify-content: flex-end;
/* Main/save button stays on the physical left */
direction: rtl;
}
.saveBtn {
@@ -164,6 +164,8 @@
align-items: flex-end;
justify-content: flex-end;
min-width: 0;
/* Main/save button stays on the physical left */
direction: rtl;
}
.saveBtn {
@@ -103,6 +103,8 @@
display: flex;
justify-content: flex-end;
margin-top: 16px;
/* Main/save button stays on the physical left */
direction: rtl;
}
.saveBtn {