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 {
|
||||
|
||||
@@ -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>> = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -16,4 +16,5 @@ export interface Product {
|
||||
status: 'active' | 'draft'
|
||||
commentCount: number
|
||||
variantCount?: number
|
||||
translationCount?: number
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user