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:
co-authored by
Cursor
parent
8c187b18be
commit
2a32c46b2e
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user