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
@@ -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;
+26 -1
View File
@@ -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 {