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
@@ -104,6 +104,8 @@
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
/* Main/submit button (last child) stays on the physical left */
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
|
||||
@@ -162,6 +162,8 @@
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
/* Main/submit button (last child) stays on the physical left */
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: var(--primary);
|
||||
background: var(--count-badge);
|
||||
border-radius: 50px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
@@ -158,7 +158,26 @@
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: #0d9488;
|
||||
background: var(--count-badge);
|
||||
border-radius: 50px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.translateBadge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: var(--count-badge);
|
||||
border-radius: 50px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { FolderPlus, Layers, Trash2, ChevronRight, ClipboardList, Pencil } from 'lucide-react'
|
||||
import { FolderPlus, Globe, Layers, Trash2, ChevronRight, ClipboardList, Pencil } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { Category } from '../types/category'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||
import { hasBusinessModule } from '../utils/businessModules'
|
||||
import styles from './CategoryRow.module.css'
|
||||
|
||||
interface CategoryRowProps {
|
||||
@@ -17,6 +19,7 @@ interface CategoryRowProps {
|
||||
onVariations: (id: string) => void
|
||||
onTechnicalForm: (id: string) => void
|
||||
onEdit: (id: string) => void
|
||||
onTranslate: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
@@ -32,11 +35,15 @@ export function CategoryRow({
|
||||
onVariations,
|
||||
onTechnicalForm,
|
||||
onEdit,
|
||||
onTranslate,
|
||||
onRemove,
|
||||
}: CategoryRowProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const { enabledModules } = useTenantBranding()
|
||||
const showTranslationControls = hasBusinessModule(enabledModules, 'multilanguage_data')
|
||||
const translationCount = category.translationCount ?? 0
|
||||
const primaryName = isFa ? category.nameFa || category.nameEn : category.nameEn
|
||||
const secondaryName = isFa
|
||||
? category.nameFa
|
||||
@@ -121,6 +128,24 @@ export function CategoryRow({
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showTranslationControls ? (
|
||||
<Tooltip label={t('categories.tooltip.translate')}>
|
||||
<button
|
||||
className={`${styles.controlBtn} ${styles.iconBtn}`}
|
||||
onClick={() => onTranslate(category.id)}
|
||||
aria-label={
|
||||
translationCount > 0
|
||||
? `${t('categories.tooltip.translate')} (${translationCount})`
|
||||
: t('categories.tooltip.translate')
|
||||
}
|
||||
>
|
||||
<Globe size={17} />
|
||||
{translationCount > 0 && (
|
||||
<span className={styles.translateBadge}>{translationCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip label={t('categories.tooltip.edit')}>
|
||||
<button
|
||||
className={styles.controlBtn}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface CategoryTreeProps {
|
||||
onVariations: (id: string) => void
|
||||
onTechnicalForm: (id: string) => void
|
||||
onEdit: (id: string) => void
|
||||
onTranslate: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
parentId?: string | null
|
||||
depth?: number
|
||||
@@ -28,6 +29,7 @@ export function CategoryTree({
|
||||
onVariations,
|
||||
onTechnicalForm,
|
||||
onEdit,
|
||||
onTranslate,
|
||||
onRemove,
|
||||
parentId = null,
|
||||
depth = 0,
|
||||
@@ -54,6 +56,7 @@ export function CategoryTree({
|
||||
onVariations={onVariations}
|
||||
onTechnicalForm={onTechnicalForm}
|
||||
onEdit={onEdit}
|
||||
onTranslate={onTranslate}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
{childCount && (
|
||||
@@ -69,6 +72,7 @@ export function CategoryTree({
|
||||
onVariations={onVariations}
|
||||
onTechnicalForm={onTechnicalForm}
|
||||
onEdit={onEdit}
|
||||
onTranslate={onTranslate}
|
||||
onRemove={onRemove}
|
||||
parentId={category.id}
|
||||
depth={depth + 1}
|
||||
|
||||
@@ -105,6 +105,8 @@
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
/* Main button (last child) stays on the physical left */
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
/* Main/delete button (last child) stays on the physical left */
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
|
||||
@@ -70,13 +70,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary);
|
||||
background: var(--count-badge);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
border-radius: 999px;
|
||||
border: 2px solid white;
|
||||
border: 2px solid var(--header-bg, var(--elevated-surface));
|
||||
}
|
||||
|
||||
.profileWrap {
|
||||
|
||||
@@ -95,6 +95,8 @@
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
/* Main/submit button (last child) stays on the physical left */
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.totalRow {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
.options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: var(--field-height);
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.option:hover:not(:disabled) {
|
||||
border-color: rgba(var(--primary-rgb) / 0.4);
|
||||
background: rgba(var(--primary-rgb) / 0.05);
|
||||
}
|
||||
|
||||
.option[data-selected='true'] {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.option:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(148, 163, 184, 0.55);
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 0 transparent;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.option[data-selected='true'] .radio {
|
||||
border-color: var(--primary);
|
||||
box-shadow: inset 0 0 0 4px var(--primary);
|
||||
}
|
||||
|
||||
.optionText {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.optionCode {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-en), var(--font-ui);
|
||||
}
|
||||
|
||||
.option[data-selected='true'] .optionCode {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.optionName {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-ui);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.option[data-selected='true'] .optionName {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { TranslationLocale } from '../types/translation'
|
||||
import { useT } from '../i18n/useT'
|
||||
import styles from './LanguageLocaleOptions.module.css'
|
||||
|
||||
const LOCALES: TranslationLocale[] = ['en', 'ar']
|
||||
|
||||
interface LanguageLocaleOptionsProps {
|
||||
value: TranslationLocale
|
||||
disabled?: boolean
|
||||
ariaLabel: string
|
||||
onChange: (locale: TranslationLocale) => void
|
||||
}
|
||||
|
||||
export function LanguageLocaleOptions({
|
||||
value,
|
||||
disabled,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
}: LanguageLocaleOptionsProps) {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<div className={styles.options} role="radiogroup" aria-label={ariaLabel}>
|
||||
{LOCALES.map((item) => {
|
||||
const selected = value === item
|
||||
const name = t(item === 'en' ? 'translations.lang.en' : 'translations.lang.ar')
|
||||
const code = item === 'en' ? 'EN' : 'AR'
|
||||
return (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={name}
|
||||
className={styles.option}
|
||||
data-selected={selected ? 'true' : undefined}
|
||||
onClick={() => onChange(item)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={styles.radio} aria-hidden />
|
||||
<span className={styles.optionText}>
|
||||
<span className={styles.optionCode} dir="ltr">
|
||||
{code}
|
||||
</span>
|
||||
<span className={styles.optionName}>{name}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,8 @@
|
||||
gap: 10px;
|
||||
padding-top: 16px;
|
||||
margin-top: 4px;
|
||||
/* Main button (last child) stays on the physical left */
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.cancelBtn,
|
||||
|
||||
@@ -201,7 +201,8 @@
|
||||
}
|
||||
|
||||
.variantBadge,
|
||||
.commentBadge {
|
||||
.commentBadge,
|
||||
.translateBadge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
@@ -214,19 +215,12 @@
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: var(--count-badge);
|
||||
border-radius: 50px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.variantBadge {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.commentBadge {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.controls button:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Pencil,
|
||||
Globe,
|
||||
Info,
|
||||
Trash2,
|
||||
Layers,
|
||||
@@ -13,6 +14,8 @@ import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { Product } from '../types/product'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||
import { hasBusinessModule } from '../utils/businessModules'
|
||||
import { formatProductCategoryChip } from '../services/productService'
|
||||
import styles from './ProductCard.module.css'
|
||||
|
||||
@@ -21,6 +24,7 @@ interface ProductCardProps {
|
||||
commentCount: number
|
||||
variantCount: number
|
||||
onEdit: (id: string) => void
|
||||
onTranslate: (id: string) => void
|
||||
onQuickInfo: (id: string) => void
|
||||
onComments: (id: string) => void
|
||||
onTechnicalInfo: (id: string) => void
|
||||
@@ -33,6 +37,7 @@ export function ProductCard({
|
||||
commentCount,
|
||||
variantCount,
|
||||
onEdit,
|
||||
onTranslate,
|
||||
onQuickInfo,
|
||||
onComments,
|
||||
onTechnicalInfo,
|
||||
@@ -42,6 +47,9 @@ export function ProductCard({
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const { enabledModules } = useTenantBranding()
|
||||
const showTranslationControls = hasBusinessModule(enabledModules, 'multilanguage_data')
|
||||
const translationCount = product.translationCount ?? 0
|
||||
const primaryName = isFa ? product.nameFa || product.nameEn : product.nameEn
|
||||
const secondaryName = isFa
|
||||
? product.nameFa
|
||||
@@ -103,6 +111,25 @@ export function ProductCard({
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showTranslationControls ? (
|
||||
<Tooltip label={t('products.card.translate')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
onClick={() => onTranslate(product.id)}
|
||||
aria-label={
|
||||
translationCount > 0
|
||||
? `${t('products.card.translate')} (${translationCount})`
|
||||
: t('products.card.translate')
|
||||
}
|
||||
>
|
||||
<Globe size={16} />
|
||||
{translationCount > 0 && (
|
||||
<span className={styles.translateBadge}>{translationCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip label={t('products.card.quickInfo')}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: var(--primary);
|
||||
background: var(--count-badge);
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
|
||||
@@ -309,6 +309,8 @@
|
||||
padding-top: 16px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
/* Main button (last child) stays on the physical left */
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
.modal {
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.headerBlock {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 18px 24px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
/* Main/run button (last child) stays on the physical left */
|
||||
direction: rtl;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Sparkles, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { TranslationLocale } from '../types/translation'
|
||||
import modalStyles from './CategoryModal.module.css'
|
||||
import { LanguageLocaleOptions } from './LanguageLocaleOptions'
|
||||
import aiStyles from '../styles/ai.module.css'
|
||||
import styles from './TranslateAllLanguageModal.module.css'
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
|
||||
interface TranslateAllLanguageModalProps {
|
||||
open: boolean
|
||||
title: string
|
||||
subtitle: string
|
||||
running: boolean
|
||||
runLabel?: string
|
||||
runningLabel?: string
|
||||
onClose: () => void
|
||||
onConfirm: (locale: TranslationLocale) => void
|
||||
}
|
||||
|
||||
export function TranslateAllLanguageModal({
|
||||
open,
|
||||
title,
|
||||
subtitle,
|
||||
running,
|
||||
runLabel,
|
||||
runningLabel,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: TranslateAllLanguageModalProps) {
|
||||
const t = useT()
|
||||
const { locale: uiLocale } = useLocale()
|
||||
const isFa = uiLocale === 'fa'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [locale, setLocale] = useState<TranslationLocale>('en')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
setLocale('en')
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
const timer = setTimeout(() => {
|
||||
setMounted(false)
|
||||
setClosing(false)
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, mounted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing || running) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [mounted, closing, running, onClose])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={() => !running && onClose()}
|
||||
>
|
||||
<div
|
||||
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="translate-all-language-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={`${modalStyles.header} ${styles.header}`}>
|
||||
<div className={styles.headerBlock}>
|
||||
<h3 id="translate-all-language-title" className={modalStyles.title}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className={styles.subtitle}>{subtitle}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={onClose}
|
||||
disabled={running}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<LanguageLocaleOptions
|
||||
value={locale}
|
||||
disabled={running}
|
||||
ariaLabel={title}
|
||||
onChange={setLocale}
|
||||
/>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.cancelBtn}
|
||||
onClick={onClose}
|
||||
disabled={running}
|
||||
>
|
||||
{t('categories.modal.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={aiStyles.aiBtn}
|
||||
onClick={() => onConfirm(locale)}
|
||||
disabled={running}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
{running
|
||||
? runningLabel || t('categories.translateAll.running')
|
||||
: runLabel || t('categories.translateAll.run')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.modal {
|
||||
max-width: 560px;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.entityName {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 16px 24px 8px;
|
||||
}
|
||||
|
||||
.langOptions {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
min-height: 160px;
|
||||
padding: 12px 0 24px;
|
||||
}
|
||||
|
||||
.emptyHint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.htmlField {
|
||||
min-height: 140px;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.form input,
|
||||
.form textarea {
|
||||
width: 100%;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
/* Last child (Save) on physical left; first (AI) on physical right */
|
||||
direction: rtl;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Sparkles, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
listEntityTranslations,
|
||||
saveEntityTranslation,
|
||||
translateEntityWithAi,
|
||||
} from '../services/translationService'
|
||||
import type {
|
||||
CategoryTranslationFields,
|
||||
ProductTranslationFields,
|
||||
TranslationEntityType,
|
||||
TranslationFields,
|
||||
TranslationLocale,
|
||||
} from '../types/translation'
|
||||
import aiStyles from '../styles/ai.module.css'
|
||||
import modalStyles from './CategoryModal.module.css'
|
||||
import { LanguageLocaleOptions } from './LanguageLocaleOptions'
|
||||
import styles from './TranslationModal.module.css'
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
|
||||
const emptyCategory = (): CategoryTranslationFields => ({ name: '', description: '' })
|
||||
const emptyProduct = (): ProductTranslationFields => ({
|
||||
title: '',
|
||||
summary: '',
|
||||
descriptionHtml: '',
|
||||
})
|
||||
|
||||
interface TranslationModalProps {
|
||||
open: boolean
|
||||
entityType: TranslationEntityType
|
||||
entityId: string
|
||||
entityName: string
|
||||
onClose: () => void
|
||||
onChanged?: (count: number) => void
|
||||
}
|
||||
|
||||
export function TranslationModal({
|
||||
open,
|
||||
entityType,
|
||||
entityId,
|
||||
entityName,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: TranslationModalProps) {
|
||||
const t = useT()
|
||||
const { showToast } = useToast()
|
||||
const { locale: dashboardLocale } = useLocale()
|
||||
const isFa = dashboardLocale === 'fa'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [tab, setTab] = useState<TranslationLocale>('en')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [translating, setTranslating] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [drafts, setDrafts] = useState<Partial<Record<TranslationLocale, TranslationFields>>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
setTab('en')
|
||||
setError('')
|
||||
setDrafts({})
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
const timer = setTimeout(() => {
|
||||
setMounted(false)
|
||||
setClosing(false)
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, mounted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !entityId) return
|
||||
const controller = new AbortController()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
void listEntityTranslations(entityType, entityId, controller.signal)
|
||||
.then((data) => {
|
||||
const next: Partial<Record<TranslationLocale, TranslationFields>> = {}
|
||||
for (const item of data.items) {
|
||||
if (item.status === 'ready' || item.status === 'stale') {
|
||||
next[item.locale] = item.fields
|
||||
}
|
||||
}
|
||||
setDrafts(next)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : t('translations.errorLoad'))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
return () => controller.abort()
|
||||
}, [open, entityType, entityId, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !translating && !saving) onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [mounted, closing, onClose, translating, saving])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
const current = drafts[tab]
|
||||
const hasTranslation = Boolean(current)
|
||||
const busy = loading || translating || saving
|
||||
|
||||
async function handleTranslate() {
|
||||
setTranslating(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await translateEntityWithAi(entityType, entityId, tab)
|
||||
const nextCount = new Set([...Object.keys(drafts), tab]).size
|
||||
setDrafts((prev) => ({ ...prev, [tab]: result.translation.fields }))
|
||||
onChanged?.(nextCount)
|
||||
showToast(t('translations.translated'), 'success')
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t('translations.errorTranslate'))
|
||||
} finally {
|
||||
setTranslating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!current) return
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await saveEntityTranslation(entityType, entityId, tab, current)
|
||||
showToast(t('translations.saved'), 'success')
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t('translations.errorSave'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function updateField(key: string, value: string) {
|
||||
setDrafts((prev) => {
|
||||
const existing = prev[tab] ?? (entityType === 'category' ? emptyCategory() : emptyProduct())
|
||||
return { ...prev, [tab]: { ...existing, [key]: value } }
|
||||
})
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={() => !translating && !saving && onClose()}
|
||||
>
|
||||
<div
|
||||
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="translation-modal-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<h3 id="translation-modal-title" className={modalStyles.title}>
|
||||
{t(
|
||||
entityType === 'product'
|
||||
? 'translations.title.product'
|
||||
: 'translations.title.category',
|
||||
)}
|
||||
{entityName ? <span className={styles.entityName}> · {entityName}</span> : null}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={onClose}
|
||||
disabled={translating || saving}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.langOptions}>
|
||||
<LanguageLocaleOptions
|
||||
value={tab}
|
||||
disabled={busy}
|
||||
ariaLabel={t(
|
||||
entityType === 'product'
|
||||
? 'translations.title.product'
|
||||
: 'translations.title.category',
|
||||
)}
|
||||
onChange={(locale) => {
|
||||
setTab(locale)
|
||||
setError('')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className={modalStyles.errorText} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className={styles.emptyHint}>{t('translations.loading')}</p>
|
||||
) : !hasTranslation ? (
|
||||
<div className={styles.empty}>
|
||||
<p className={styles.emptyHint}>{t('translations.emptyHint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={aiStyles.aiBtn}
|
||||
onClick={() => void handleTranslate()}
|
||||
disabled={translating}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
{translating ? t('translations.translating') : t('translations.translate')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(e) => void handleSave(e)}
|
||||
dir={tab === 'ar' ? 'rtl' : 'ltr'}
|
||||
lang={tab}
|
||||
>
|
||||
{entityType === 'category' ? (
|
||||
<>
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="translation-name">{t('translations.field.name')}</label>
|
||||
<input
|
||||
id="translation-name"
|
||||
value={(current as CategoryTranslationFields).name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="translation-description">
|
||||
{t('translations.field.description')}
|
||||
</label>
|
||||
<textarea
|
||||
id="translation-description"
|
||||
rows={3}
|
||||
value={(current as CategoryTranslationFields).description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="translation-title">{t('translations.field.title')}</label>
|
||||
<input
|
||||
id="translation-title"
|
||||
value={(current as ProductTranslationFields).title}
|
||||
onChange={(e) => updateField('title', e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="translation-summary">{t('translations.field.summary')}</label>
|
||||
<textarea
|
||||
id="translation-summary"
|
||||
rows={3}
|
||||
value={(current as ProductTranslationFields).summary}
|
||||
onChange={(e) => updateField('summary', e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="translation-html">
|
||||
{t('translations.field.descriptionHtml')}
|
||||
</label>
|
||||
<textarea
|
||||
id="translation-html"
|
||||
className={styles.htmlField}
|
||||
rows={6}
|
||||
value={(current as ProductTranslationFields).descriptionHtml}
|
||||
onChange={(e) => updateField('descriptionHtml', e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={aiStyles.aiBtn}
|
||||
onClick={() => void handleTranslate()}
|
||||
disabled={busy}
|
||||
>
|
||||
<Sparkles size={15} />
|
||||
{translating ? t('translations.translating') : t('translations.translateAgain')}
|
||||
</button>
|
||||
<button type="submit" className={modalStyles.submitBtn} disabled={busy}>
|
||||
{saving ? t('translations.saving') : t('translations.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -416,6 +416,8 @@
|
||||
padding-top: 16px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
/* Main/submit button (last child) stays on the physical left */
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
|
||||
Reference in New Issue
Block a user