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 {
+90
View File
@@ -347,6 +347,16 @@ const en = {
'products.list.deleteMessage':
'Are you sure you want to delete "{name}"? This action cannot be undone.',
'products.list.addByAi': 'Add product by AI',
'products.list.aiMenu': 'Artificial Intelligence',
'products.list.translateAll': 'Translate all products',
'products.list.translateAll.pickTitle': 'Translate all products',
'products.list.translateAll.pickSubtitle':
'Choose which language to generate for products that are missing it.',
'products.list.translateAll.run': 'Translate',
'products.list.translateAll.running': 'Translating…',
'products.list.translateAll.done': 'Translated {translated} products to {lang}.',
'products.list.translateAll.none': 'All products already have this language.',
'products.list.translateAll.error': 'Unable to translate all products.',
'products.list.addNew': 'Add new product',
'customerProducts.title': 'Customer products',
@@ -521,6 +531,7 @@ const en = {
'customerProducts.priceUnavailable': 'Price on request',
'products.card.edit': 'Edit product',
'products.card.translate': 'Translate',
'products.card.quickInfo': 'Quick info',
'products.card.variations': 'Manage variations',
'products.card.comments': 'View comments',
@@ -621,6 +632,16 @@ const en = {
'categories.addSub': 'Add Sub Category',
'categories.edit': 'Edit Category',
'categories.addByAi': 'Fill categories by AI',
'categories.aiMenu': 'Artificial Intelligence',
'categories.translateAll': 'Translate all categories',
'categories.translateAll.pickTitle': 'Translate all categories',
'categories.translateAll.pickSubtitle':
'Choose which language to generate for categories that are missing it.',
'categories.translateAll.run': 'Translate',
'categories.translateAll.running': 'Translating…',
'categories.translateAll.done': 'Translated {translated} categories to {lang}.',
'categories.translateAll.none': 'All categories already have this language.',
'categories.translateAll.error': 'Unable to translate all categories.',
'categories.ai.title': 'Fill Categories with AI',
'categories.ai.subtitle':
'Edit the prompt below, then run it to generate and save a category tree.',
@@ -630,6 +651,7 @@ const en = {
'Are you sure you want to delete "{name}"? Subcategories will also be removed.',
'categories.tooltip.addSub': 'Add sub category',
'categories.tooltip.edit': 'Edit category',
'categories.tooltip.translate': 'Translate',
'categories.tooltip.variations': 'Manage variations',
'categories.tooltip.technical': 'Technical data form',
'categories.tooltip.options': 'Category options',
@@ -2338,6 +2360,29 @@ const en = {
'common.codeSent': 'Verification code sent to {phone}',
'common.breadcrumb': 'Breadcrumb',
'common.overview': 'Overview',
'translations.title': 'Translations',
'translations.title.product': 'Product translation',
'translations.title.category': 'Category translation',
'translations.lang.en': 'English',
'translations.lang.ar': 'Arabic',
'translations.emptyHint': 'No translation yet. Generate one with AI, then edit if needed.',
'translations.loading': 'Loading translation…',
'translations.translate': 'Translate',
'translations.translating': 'Translating…',
'translations.translateAgain': 'Translate again',
'translations.save': 'Save',
'translations.saving': 'Saving…',
'translations.saved': 'Translation saved.',
'translations.translated': 'Translation generated.',
'translations.errorLoad': 'Unable to load translation.',
'translations.errorTranslate': 'Unable to generate translation.',
'translations.errorSave': 'Unable to save translation.',
'translations.field.name': 'Name',
'translations.field.description': 'Description',
'translations.field.title': 'Title',
'translations.field.summary': 'Summary',
'translations.field.descriptionHtml': 'Description',
} as const
type MessageKey = keyof typeof en
@@ -2686,6 +2731,16 @@ const fa: Record<MessageKey, string> = {
'products.list.deleteMessage':
'آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.',
'products.list.addByAi': 'افزودن محصول با هوش مصنوعی',
'products.list.aiMenu': 'هوش مصنوعی',
'products.list.translateAll': 'ترجمه همه محصولات',
'products.list.translateAll.pickTitle': 'ترجمه همه محصولات',
'products.list.translateAll.pickSubtitle':
'زبانی را انتخاب کنید که برای محصولات بدون ترجمه ساخته شود.',
'products.list.translateAll.run': 'ترجمه',
'products.list.translateAll.running': 'در حال ترجمه…',
'products.list.translateAll.done': '{translated} محصول به {lang} ترجمه شد.',
'products.list.translateAll.none': 'همه محصولات از قبل این زبان را دارند.',
'products.list.translateAll.error': 'ترجمه همه محصولات ممکن نشد.',
'products.list.addNew': 'افزودن محصول جدید',
'customerProducts.title': 'محصولات مشتریان',
@@ -2860,6 +2915,7 @@ const fa: Record<MessageKey, string> = {
'customerProducts.priceUnavailable': 'قیمت اعلام نشده',
'products.card.edit': 'ویرایش محصول',
'products.card.translate': 'ترجمه',
'products.card.quickInfo': 'اطلاعات سریع',
'products.card.variations': 'مدیریت تنوع‌ها',
'products.card.comments': 'مشاهده نظرات',
@@ -2958,6 +3014,16 @@ const fa: Record<MessageKey, string> = {
'categories.addSub': 'افزودن زیردسته',
'categories.edit': 'ویرایش دسته‌بندی',
'categories.addByAi': 'پر کردن دسته‌ها با هوش مصنوعی',
'categories.aiMenu': 'هوش مصنوعی',
'categories.translateAll': 'ترجمه همه دسته‌ها',
'categories.translateAll.pickTitle': 'ترجمه همه دسته‌ها',
'categories.translateAll.pickSubtitle':
'زبانی را انتخاب کنید که برای دسته‌های بدون ترجمه ساخته شود.',
'categories.translateAll.run': 'ترجمه',
'categories.translateAll.running': 'در حال ترجمه…',
'categories.translateAll.done': '{translated} دسته به {lang} ترجمه شد.',
'categories.translateAll.none': 'همه دسته‌ها از قبل این زبان را دارند.',
'categories.translateAll.error': 'ترجمه همه دسته‌ها ممکن نشد.',
'categories.ai.title': 'پر کردن دسته‌ها با هوش مصنوعی',
'categories.ai.subtitle':
'پرامپت زیر را ویرایش کنید، سپس اجرا کنید تا درخت دسته‌بندی ساخته و ذخیره شود.',
@@ -2967,6 +3033,7 @@ const fa: Record<MessageKey, string> = {
'آیا از حذف «{name}» مطمئن هستید؟ زیردسته‌ها نیز حذف می‌شوند.',
'categories.tooltip.addSub': 'افزودن زیردسته',
'categories.tooltip.edit': 'ویرایش دسته‌بندی',
'categories.tooltip.translate': 'ترجمه',
'categories.tooltip.variations': 'مدیریت تنوع‌ها',
'categories.tooltip.technical': 'فرم اطلاعات فنی',
'categories.tooltip.options': 'گزینه‌های دسته‌بندی',
@@ -4677,6 +4744,29 @@ const fa: Record<MessageKey, string> = {
'common.codeSent': 'کد تأیید به {phone} ارسال شد',
'common.breadcrumb': 'مسیر صفحه',
'common.overview': 'نمای کلی',
'translations.title': 'ترجمه‌ها',
'translations.title.product': 'ترجمه ی محصول',
'translations.title.category': 'ترجمه ی دسته',
'translations.lang.en': 'انگلیسی',
'translations.lang.ar': 'عربی',
'translations.emptyHint': 'هنوز ترجمه‌ای نیست. با هوش مصنوعی بسازید و در صورت نیاز ویرایش کنید.',
'translations.loading': 'در حال بارگذاری ترجمه…',
'translations.translate': 'ترجمه',
'translations.translating': 'در حال ترجمه…',
'translations.translateAgain': 'ترجمه دوباره',
'translations.save': 'ذخیره',
'translations.saving': 'در حال ذخیره…',
'translations.saved': 'ترجمه ذخیره شد.',
'translations.translated': 'ترجمه ساخته شد.',
'translations.errorLoad': 'بارگذاری ترجمه ممکن نشد.',
'translations.errorTranslate': 'تولید ترجمه ممکن نشد.',
'translations.errorSave': 'ذخیره ترجمه ممکن نشد.',
'translations.field.name': 'نام',
'translations.field.description': 'توضیحات',
'translations.field.title': 'عنوان',
'translations.field.summary': 'خلاصه',
'translations.field.descriptionHtml': 'توضیحات',
}
const dictionaries: Record<DashboardLocale, Record<MessageKey, string>> = {
+2
View File
@@ -13,6 +13,8 @@
--primary-dark: #2563eb;
--primary-rgb: 59 130 246;
--primary-dark-rgb: 37 99 235;
--count-badge: #ee3552;
--count-badge-rgb: 238 53 82;
--chart-accent: #06b6d4;
--chart-accent-dark: #0891b2;
--chart-accent-rgb: 6 182 212;
@@ -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 {
@@ -86,5 +86,6 @@ export function mapProductCategoryToUi(category: ProductCategory) {
description: category.description ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
translationCount: category.translationCount ?? 0,
}
}
@@ -35,6 +35,7 @@ export interface ProductApi {
galleryMediaIds: string[]
commentCount: number
variantCount: number
translationCount?: number
createdAt: string
updatedAt: string
}
@@ -99,6 +100,7 @@ export function mapProductApiToUi(product: ProductApi): Product {
status: product.status === 'published' ? 'active' : 'draft',
commentCount: product.commentCount,
variantCount: product.variantCount ?? 0,
translationCount: product.translationCount ?? 0,
}
}
@@ -0,0 +1,96 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
EntityTranslation,
EntityTranslationsResponse,
TranslationEntityType,
TranslationFields,
TranslationLocale,
} from '../types/translation'
function businessPath(entityType: TranslationEntityType, entityId: string, suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
const resource = entityType === 'product' ? 'products' : 'categories'
return `/businesses/${businessId}/${resource}/${entityId}/translations${suffix}`
}
export async function listEntityTranslations(
entityType: TranslationEntityType,
entityId: string,
signal?: AbortSignal,
) {
return apiRequest<EntityTranslationsResponse>(businessPath(entityType, entityId), {
auth: true,
signal,
})
}
export async function translateEntityWithAi(
entityType: TranslationEntityType,
entityId: string,
locale: TranslationLocale,
) {
return apiRequest<{ message: string; translation: EntityTranslation }>(
businessPath(entityType, entityId, `/${locale}/ai`),
{
method: 'POST',
auth: true,
body: {},
},
)
}
export async function saveEntityTranslation(
entityType: TranslationEntityType,
entityId: string,
locale: TranslationLocale,
fields: TranslationFields,
) {
return apiRequest<{ message: string; translation: EntityTranslation }>(
businessPath(entityType, entityId, `/${locale}`),
{
method: 'PUT',
auth: true,
body: { fields },
},
)
}
export async function translateAllProductCategories(locale: TranslationLocale) {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return apiRequest<{
message: string
locale: TranslationLocale
translated: number
skipped: number
failed: number
}>(`/businesses/${businessId}/categories/translations/ai-all`, {
method: 'POST',
auth: true,
body: { locale },
})
}
export async function translateAllProducts(locale: TranslationLocale) {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return apiRequest<{
message: string
locale: TranslationLocale
translated: number
skipped: number
failed: number
}>(`/businesses/${businessId}/products/translations/ai-all`, {
method: 'POST',
auth: true,
body: { locale },
})
}
+166 -5
View File
@@ -58,13 +58,14 @@
}
.aiBtn:hover:not(:disabled),
.aiFabStrip:hover {
.aiFabStrip:hover:not(:disabled) {
box-shadow:
0 6px 20px rgba(67, 56, 202, 0.34),
0 0 32px rgba(236, 72, 153, 0.22);
}
.aiBtn:disabled {
.aiBtn:disabled,
.aiFabStrip:disabled {
opacity: 0.6;
cursor: not-allowed;
animation-play-state: paused;
@@ -85,11 +86,16 @@
0 0 32px rgba(236, 72, 153, 0.18);
}
.aiPromptField textarea:focus,
textarea.aiPromptTextarea:focus {
.aiPromptField textarea.aiPromptTextarea,
.aiPromptField textarea,
textarea.aiPromptTextarea {
border: 2px solid transparent;
background-color: var(--surface, var(--elevated-surface));
background-image:
linear-gradient(var(--elevated-surface), var(--elevated-surface)),
linear-gradient(
var(--surface, var(--elevated-surface)),
var(--surface, var(--elevated-surface))
),
linear-gradient(
90deg,
#ff375f 0%,
@@ -110,6 +116,153 @@ textarea.aiPromptTextarea:focus {
0 0 16px rgba(255, 55, 95, 0.32),
0 0 28px rgba(10, 132, 255, 0.24),
0 0 42px rgba(191, 90, 242, 0.18);
transition: box-shadow 0.2s ease;
}
.aiPromptField textarea.aiPromptTextarea:focus,
.aiPromptField textarea:focus,
textarea.aiPromptTextarea:focus {
border: 2px solid transparent;
border-color: transparent;
outline: none;
background-image:
linear-gradient(
var(--surface, var(--elevated-surface)),
var(--surface, var(--elevated-surface))
),
linear-gradient(
90deg,
#ff375f 0%,
#ff8a00 14%,
#ffd60a 28%,
#30d158 42%,
#64d2ff 56%,
#0a84ff 70%,
#bf5af2 84%,
#ff375f 100%
);
background-size: auto, 200% 100%;
background-origin: border-box;
background-clip: padding-box, border-box;
animation: aiRainbowShift 4s linear infinite;
box-shadow:
0 0 0 1px rgba(191, 90, 242, 0.18),
0 0 18px rgba(255, 55, 95, 0.38),
0 0 32px rgba(10, 132, 255, 0.28),
0 0 48px rgba(191, 90, 242, 0.22);
}
.aiPromptField textarea.aiPromptTextarea:disabled,
.aiPromptField textarea:disabled,
textarea.aiPromptTextarea:disabled {
opacity: 0.7;
animation-play-state: paused;
cursor: not-allowed;
}
.aiFabGroup {
position: relative;
display: inline-flex;
align-items: center;
}
.aiFabMenu {
position: absolute;
bottom: calc(100% + 8px);
inset-inline-end: 0;
z-index: 120;
isolation: isolate;
display: flex;
flex-direction: column;
gap: 4px;
min-width: max(100%, 240px);
padding: 6px;
border-radius: 14px;
border: 2px solid transparent;
background-color: var(--toast-bg, rgba(255, 255, 255, 0.94));
background-image:
linear-gradient(
var(--toast-bg, rgba(255, 255, 255, 0.94)),
var(--toast-bg, rgba(255, 255, 255, 0.94))
),
linear-gradient(
90deg,
#ff375f 0%,
#ff8a00 14%,
#ffd60a 28%,
#30d158 42%,
#64d2ff 56%,
#0a84ff 70%,
#bf5af2 84%,
#ff375f 100%
);
background-size: auto, 200% 100%;
background-origin: border-box;
background-clip: padding-box, border-box;
animation: aiRainbowShift 4s linear infinite;
box-shadow:
0 18px 48px rgba(15, 23, 42, 0.22),
0 0 16px rgba(255, 55, 95, 0.28),
0 0 28px rgba(10, 132, 255, 0.22),
0 0 42px rgba(191, 90, 242, 0.18);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.16s ease, visibility 0.16s ease;
}
/* Invisible hit bridge so the cursor can reach options without closing the menu */
.aiFabMenu::after {
content: '';
position: absolute;
left: 0;
right: 0;
top: 100%;
height: 16px;
}
.aiFabMenu::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
pointer-events: none;
backdrop-filter: blur(var(--blur-glass)) saturate(1.85);
-webkit-backdrop-filter: blur(var(--blur-glass)) saturate(1.85);
}
.aiFabGroup:hover .aiFabMenu,
.aiFabGroup[data-open='true'] .aiFabMenu {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.aiFabMenuItem {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
font-family: var(--font-ui);
color: var(--text-primary);
text-align: start;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.aiFabMenuItem:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.08);
color: var(--primary);
}
.aiFabMenuItem:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@media (max-width: 768px) {
@@ -122,4 +275,12 @@ textarea.aiPromptTextarea:focus {
.aiFabStrip span {
display: none;
}
.aiFabMenu {
min-width: 220px;
}
.aiFabMenuItem {
white-space: normal;
}
}
+2
View File
@@ -5,6 +5,7 @@ export interface Category {
description: string
parentId: string | null
variationCount: number
translationCount?: number
}
export interface CategoryFormData {
@@ -30,6 +31,7 @@ export interface ProductCategory {
sortOrder: number
isActive: boolean
variationCount?: number
translationCount?: number
createdAt: string
updatedAt: string
}
+1
View File
@@ -16,4 +16,5 @@ export interface Product {
status: 'active' | 'draft'
commentCount: number
variantCount?: number
translationCount?: number
}
+29
View File
@@ -0,0 +1,29 @@
export type TranslationLocale = 'en' | 'ar'
export type TranslationEntityType = 'product' | 'category'
export type CategoryTranslationFields = {
name: string
description: string
}
export type ProductTranslationFields = {
title: string
summary: string
descriptionHtml: string
}
export type TranslationFields = CategoryTranslationFields | ProductTranslationFields
export type EntityTranslation = {
locale: TranslationLocale
status: 'pending' | 'ready' | 'stale' | 'failed'
fields: TranslationFields
generatedBy: string
updatedAt: string
}
export type EntityTranslationsResponse = {
entityType: TranslationEntityType
entityId: string
items: EntityTranslation[]
}
+4 -1
View File
@@ -9,6 +9,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
'instructions',
'workshops',
'finance',
'multilanguage_data',
] as const
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
@@ -51,7 +52,9 @@ export const DEFAULT_NEW_BUSINESS_MODULES: BusinessModuleId[] = [
* enabled except opt-in modules. Customer modules stay opt-in.
*/
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
BUSINESS_DASHBOARD_MODULE_IDS.filter((id) => id !== 'workshops')
BUSINESS_DASHBOARD_MODULE_IDS.filter(
(id) => id !== 'workshops' && id !== 'multilanguage_data',
)
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
'orders_30d',
@@ -494,6 +494,22 @@
border-top: 1px solid rgba(148, 163, 184, 0.28);
}
.modulesChartsRow {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
}
@media (max-width: 560px) {
.modulesChartsRow {
grid-template-columns: 1fr;
}
}
.modulesChecks .entityCheck span {
white-space: nowrap;
}
.modulesCharts .field select {
appearance: none;
-webkit-appearance: none;
+40 -37
View File
@@ -2087,6 +2087,7 @@ export function BusinessesPage() {
: 'Modules'
}
onClose={closeModules}
wide
>
{modulesError ? (
<p className={styles.alertError} role="alert">
@@ -2141,43 +2142,45 @@ export function BusinessesPage() {
</div>
<div className={styles.modulesCharts}>
<p className={styles.entityGroupLabel}>Home charts</p>
<div className={styles.field}>
<label htmlFor="modules-chart-1">Chart 1</label>
<select
id="modules-chart-1"
value={modulesCharts[0]}
disabled={modulesSubmitting}
onChange={(e) => {
const value = e.target.value
if (!HOME_CHART_IDS.includes(value as HomeChartId)) return
setModulesCharts([value as HomeChartId, modulesCharts[1]])
}}
>
{HOME_CHART_IDS.map((id) => (
<option key={id} value={id}>
{HOME_CHART_LABELS[id]}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label htmlFor="modules-chart-2">Chart 2</label>
<select
id="modules-chart-2"
value={modulesCharts[1]}
disabled={modulesSubmitting}
onChange={(e) => {
const value = e.target.value
if (!HOME_CHART_IDS.includes(value as HomeChartId)) return
setModulesCharts([modulesCharts[0], value as HomeChartId])
}}
>
{HOME_CHART_IDS.map((id) => (
<option key={id} value={id}>
{HOME_CHART_LABELS[id]}
</option>
))}
</select>
<div className={styles.modulesChartsRow}>
<div className={styles.field}>
<label htmlFor="modules-chart-1">Chart 1</label>
<select
id="modules-chart-1"
value={modulesCharts[0]}
disabled={modulesSubmitting}
onChange={(e) => {
const value = e.target.value
if (!HOME_CHART_IDS.includes(value as HomeChartId)) return
setModulesCharts([value as HomeChartId, modulesCharts[1]])
}}
>
{HOME_CHART_IDS.map((id) => (
<option key={id} value={id}>
{HOME_CHART_LABELS[id]}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label htmlFor="modules-chart-2">Chart 2</label>
<select
id="modules-chart-2"
value={modulesCharts[1]}
disabled={modulesSubmitting}
onChange={(e) => {
const value = e.target.value
if (!HOME_CHART_IDS.includes(value as HomeChartId)) return
setModulesCharts([modulesCharts[0], value as HomeChartId])
}}
>
{HOME_CHART_IDS.map((id) => (
<option key={id} value={id}>
{HOME_CHART_LABELS[id]}
</option>
))}
</select>
</div>
</div>
</div>
<div style={{ height: 12 }} />
@@ -9,6 +9,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
'instructions',
'workshops',
'finance',
'multilanguage_data',
] as const
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
@@ -38,6 +39,7 @@ export const BUSINESS_DASHBOARD_MODULE_LABELS: Record<
instructions: 'Instructions',
workshops: 'Workshops',
finance: 'Finance',
multilanguage_data: 'MultiLanguage Data',
}
export const CUSTOMER_MODULE_LABELS: Record<CustomerModuleId, string> = {
@@ -79,7 +81,9 @@ export const HOME_CHART_LABELS: Record<HomeChartId, string> = {
* enabled except opt-in modules. Customer modules stay opt-in.
*/
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
BUSINESS_DASHBOARD_MODULE_IDS.filter((id) => id !== 'workshops')
BUSINESS_DASHBOARD_MODULE_IDS.filter(
(id) => id !== 'workshops' && id !== 'multilanguage_data',
)
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
'orders_30d',