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;
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
|
/* Main/submit button (last child) stays on the physical left */
|
||||||
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancelBtn {
|
.cancelBtn {
|
||||||
|
|||||||
@@ -162,6 +162,8 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
|
/* Main/submit button (last child) stays on the physical left */
|
||||||
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancelBtn {
|
.cancelBtn {
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: white;
|
color: white;
|
||||||
background: var(--primary);
|
background: var(--count-badge);
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -158,7 +158,26 @@
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: white;
|
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;
|
border-radius: 50px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
pointer-events: none;
|
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 { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import type { Category } from '../types/category'
|
import type { Category } from '../types/category'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||||
|
import { hasBusinessModule } from '../utils/businessModules'
|
||||||
import styles from './CategoryRow.module.css'
|
import styles from './CategoryRow.module.css'
|
||||||
|
|
||||||
interface CategoryRowProps {
|
interface CategoryRowProps {
|
||||||
@@ -17,6 +19,7 @@ interface CategoryRowProps {
|
|||||||
onVariations: (id: string) => void
|
onVariations: (id: string) => void
|
||||||
onTechnicalForm: (id: string) => void
|
onTechnicalForm: (id: string) => void
|
||||||
onEdit: (id: string) => void
|
onEdit: (id: string) => void
|
||||||
|
onTranslate: (id: string) => void
|
||||||
onRemove: (id: string) => void
|
onRemove: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,11 +35,15 @@ export function CategoryRow({
|
|||||||
onVariations,
|
onVariations,
|
||||||
onTechnicalForm,
|
onTechnicalForm,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onTranslate,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: CategoryRowProps) {
|
}: CategoryRowProps) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const { locale } = useLocale()
|
const { locale } = useLocale()
|
||||||
const isFa = locale === 'fa'
|
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 primaryName = isFa ? category.nameFa || category.nameEn : category.nameEn
|
||||||
const secondaryName = isFa
|
const secondaryName = isFa
|
||||||
? category.nameFa
|
? category.nameFa
|
||||||
@@ -121,6 +128,24 @@ export function CategoryRow({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</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')}>
|
<Tooltip label={t('categories.tooltip.edit')}>
|
||||||
<button
|
<button
|
||||||
className={styles.controlBtn}
|
className={styles.controlBtn}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface CategoryTreeProps {
|
|||||||
onVariations: (id: string) => void
|
onVariations: (id: string) => void
|
||||||
onTechnicalForm: (id: string) => void
|
onTechnicalForm: (id: string) => void
|
||||||
onEdit: (id: string) => void
|
onEdit: (id: string) => void
|
||||||
|
onTranslate: (id: string) => void
|
||||||
onRemove: (id: string) => void
|
onRemove: (id: string) => void
|
||||||
parentId?: string | null
|
parentId?: string | null
|
||||||
depth?: number
|
depth?: number
|
||||||
@@ -28,6 +29,7 @@ export function CategoryTree({
|
|||||||
onVariations,
|
onVariations,
|
||||||
onTechnicalForm,
|
onTechnicalForm,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onTranslate,
|
||||||
onRemove,
|
onRemove,
|
||||||
parentId = null,
|
parentId = null,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
@@ -54,6 +56,7 @@ export function CategoryTree({
|
|||||||
onVariations={onVariations}
|
onVariations={onVariations}
|
||||||
onTechnicalForm={onTechnicalForm}
|
onTechnicalForm={onTechnicalForm}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
|
onTranslate={onTranslate}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
/>
|
/>
|
||||||
{childCount && (
|
{childCount && (
|
||||||
@@ -69,6 +72,7 @@ export function CategoryTree({
|
|||||||
onVariations={onVariations}
|
onVariations={onVariations}
|
||||||
onTechnicalForm={onTechnicalForm}
|
onTechnicalForm={onTechnicalForm}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
|
onTranslate={onTranslate}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
parentId={category.id}
|
parentId={category.id}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
|
|||||||
@@ -105,6 +105,8 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
/* Main button (last child) stays on the physical left */
|
||||||
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancelBtn {
|
.cancelBtn {
|
||||||
|
|||||||
@@ -55,6 +55,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
/* Main/delete button (last child) stays on the physical left */
|
||||||
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancelBtn {
|
.cancelBtn {
|
||||||
|
|||||||
@@ -70,13 +70,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: var(--primary);
|
background: var(--count-badge);
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 2px solid white;
|
border: 2px solid var(--header-bg, var(--elevated-surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
.profileWrap {
|
.profileWrap {
|
||||||
|
|||||||
@@ -95,6 +95,8 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
/* Main/submit button (last child) stays on the physical left */
|
||||||
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.totalRow {
|
.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;
|
gap: 10px;
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
/* Main button (last child) stays on the physical left */
|
||||||
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancelBtn,
|
.cancelBtn,
|
||||||
|
|||||||
@@ -201,7 +201,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.variantBadge,
|
.variantBadge,
|
||||||
.commentBadge {
|
.commentBadge,
|
||||||
|
.translateBadge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -4px;
|
||||||
right: -4px;
|
right: -4px;
|
||||||
@@ -214,19 +215,12 @@
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: white;
|
color: white;
|
||||||
|
background: var(--count-badge);
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.variantBadge {
|
|
||||||
background: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.commentBadge {
|
|
||||||
background: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button:hover {
|
.controls button:hover {
|
||||||
background: rgba(var(--primary-rgb) / 0.1);
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Globe,
|
||||||
Info,
|
Info,
|
||||||
Trash2,
|
Trash2,
|
||||||
Layers,
|
Layers,
|
||||||
@@ -13,6 +14,8 @@ import { useLocale } from '@meshkee/dashboard-ui'
|
|||||||
import type { Product } from '../types/product'
|
import type { Product } from '../types/product'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||||
|
import { hasBusinessModule } from '../utils/businessModules'
|
||||||
import { formatProductCategoryChip } from '../services/productService'
|
import { formatProductCategoryChip } from '../services/productService'
|
||||||
import styles from './ProductCard.module.css'
|
import styles from './ProductCard.module.css'
|
||||||
|
|
||||||
@@ -21,6 +24,7 @@ interface ProductCardProps {
|
|||||||
commentCount: number
|
commentCount: number
|
||||||
variantCount: number
|
variantCount: number
|
||||||
onEdit: (id: string) => void
|
onEdit: (id: string) => void
|
||||||
|
onTranslate: (id: string) => void
|
||||||
onQuickInfo: (id: string) => void
|
onQuickInfo: (id: string) => void
|
||||||
onComments: (id: string) => void
|
onComments: (id: string) => void
|
||||||
onTechnicalInfo: (id: string) => void
|
onTechnicalInfo: (id: string) => void
|
||||||
@@ -33,6 +37,7 @@ export function ProductCard({
|
|||||||
commentCount,
|
commentCount,
|
||||||
variantCount,
|
variantCount,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onTranslate,
|
||||||
onQuickInfo,
|
onQuickInfo,
|
||||||
onComments,
|
onComments,
|
||||||
onTechnicalInfo,
|
onTechnicalInfo,
|
||||||
@@ -42,6 +47,9 @@ export function ProductCard({
|
|||||||
const t = useT()
|
const t = useT()
|
||||||
const { locale } = useLocale()
|
const { locale } = useLocale()
|
||||||
const isFa = locale === 'fa'
|
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 primaryName = isFa ? product.nameFa || product.nameEn : product.nameEn
|
||||||
const secondaryName = isFa
|
const secondaryName = isFa
|
||||||
? product.nameFa
|
? product.nameFa
|
||||||
@@ -103,6 +111,25 @@ export function ProductCard({
|
|||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</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')}>
|
<Tooltip label={t('products.card.quickInfo')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: white;
|
color: white;
|
||||||
background: var(--primary);
|
background: var(--count-badge);
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -309,6 +309,8 @@
|
|||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
/* Main button (last child) stays on the physical left */
|
||||||
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondaryBtn {
|
.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;
|
padding-top: 16px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
/* Main/submit button (last child) stays on the physical left */
|
||||||
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancelBtn {
|
.cancelBtn {
|
||||||
|
|||||||
@@ -347,6 +347,16 @@ const en = {
|
|||||||
'products.list.deleteMessage':
|
'products.list.deleteMessage':
|
||||||
'Are you sure you want to delete "{name}"? This action cannot be undone.',
|
'Are you sure you want to delete "{name}"? This action cannot be undone.',
|
||||||
'products.list.addByAi': 'Add product by AI',
|
'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',
|
'products.list.addNew': 'Add new product',
|
||||||
|
|
||||||
'customerProducts.title': 'Customer products',
|
'customerProducts.title': 'Customer products',
|
||||||
@@ -521,6 +531,7 @@ const en = {
|
|||||||
'customerProducts.priceUnavailable': 'Price on request',
|
'customerProducts.priceUnavailable': 'Price on request',
|
||||||
|
|
||||||
'products.card.edit': 'Edit product',
|
'products.card.edit': 'Edit product',
|
||||||
|
'products.card.translate': 'Translate',
|
||||||
'products.card.quickInfo': 'Quick info',
|
'products.card.quickInfo': 'Quick info',
|
||||||
'products.card.variations': 'Manage variations',
|
'products.card.variations': 'Manage variations',
|
||||||
'products.card.comments': 'View comments',
|
'products.card.comments': 'View comments',
|
||||||
@@ -621,6 +632,16 @@ const en = {
|
|||||||
'categories.addSub': 'Add Sub Category',
|
'categories.addSub': 'Add Sub Category',
|
||||||
'categories.edit': 'Edit Category',
|
'categories.edit': 'Edit Category',
|
||||||
'categories.addByAi': 'Fill categories by AI',
|
'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.title': 'Fill Categories with AI',
|
||||||
'categories.ai.subtitle':
|
'categories.ai.subtitle':
|
||||||
'Edit the prompt below, then run it to generate and save a category tree.',
|
'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.',
|
'Are you sure you want to delete "{name}"? Subcategories will also be removed.',
|
||||||
'categories.tooltip.addSub': 'Add sub category',
|
'categories.tooltip.addSub': 'Add sub category',
|
||||||
'categories.tooltip.edit': 'Edit category',
|
'categories.tooltip.edit': 'Edit category',
|
||||||
|
'categories.tooltip.translate': 'Translate',
|
||||||
'categories.tooltip.variations': 'Manage variations',
|
'categories.tooltip.variations': 'Manage variations',
|
||||||
'categories.tooltip.technical': 'Technical data form',
|
'categories.tooltip.technical': 'Technical data form',
|
||||||
'categories.tooltip.options': 'Category options',
|
'categories.tooltip.options': 'Category options',
|
||||||
@@ -2338,6 +2360,29 @@ const en = {
|
|||||||
'common.codeSent': 'Verification code sent to {phone}',
|
'common.codeSent': 'Verification code sent to {phone}',
|
||||||
'common.breadcrumb': 'Breadcrumb',
|
'common.breadcrumb': 'Breadcrumb',
|
||||||
'common.overview': 'Overview',
|
'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
|
} as const
|
||||||
|
|
||||||
type MessageKey = keyof typeof en
|
type MessageKey = keyof typeof en
|
||||||
@@ -2686,6 +2731,16 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'products.list.deleteMessage':
|
'products.list.deleteMessage':
|
||||||
'آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.',
|
'آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.',
|
||||||
'products.list.addByAi': 'افزودن محصول با هوش مصنوعی',
|
'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': 'افزودن محصول جدید',
|
'products.list.addNew': 'افزودن محصول جدید',
|
||||||
|
|
||||||
'customerProducts.title': 'محصولات مشتریان',
|
'customerProducts.title': 'محصولات مشتریان',
|
||||||
@@ -2860,6 +2915,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'customerProducts.priceUnavailable': 'قیمت اعلام نشده',
|
'customerProducts.priceUnavailable': 'قیمت اعلام نشده',
|
||||||
|
|
||||||
'products.card.edit': 'ویرایش محصول',
|
'products.card.edit': 'ویرایش محصول',
|
||||||
|
'products.card.translate': 'ترجمه',
|
||||||
'products.card.quickInfo': 'اطلاعات سریع',
|
'products.card.quickInfo': 'اطلاعات سریع',
|
||||||
'products.card.variations': 'مدیریت تنوعها',
|
'products.card.variations': 'مدیریت تنوعها',
|
||||||
'products.card.comments': 'مشاهده نظرات',
|
'products.card.comments': 'مشاهده نظرات',
|
||||||
@@ -2958,6 +3014,16 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'categories.addSub': 'افزودن زیردسته',
|
'categories.addSub': 'افزودن زیردسته',
|
||||||
'categories.edit': 'ویرایش دستهبندی',
|
'categories.edit': 'ویرایش دستهبندی',
|
||||||
'categories.addByAi': 'پر کردن دستهها با هوش مصنوعی',
|
'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.title': 'پر کردن دستهها با هوش مصنوعی',
|
||||||
'categories.ai.subtitle':
|
'categories.ai.subtitle':
|
||||||
'پرامپت زیر را ویرایش کنید، سپس اجرا کنید تا درخت دستهبندی ساخته و ذخیره شود.',
|
'پرامپت زیر را ویرایش کنید، سپس اجرا کنید تا درخت دستهبندی ساخته و ذخیره شود.',
|
||||||
@@ -2967,6 +3033,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'آیا از حذف «{name}» مطمئن هستید؟ زیردستهها نیز حذف میشوند.',
|
'آیا از حذف «{name}» مطمئن هستید؟ زیردستهها نیز حذف میشوند.',
|
||||||
'categories.tooltip.addSub': 'افزودن زیردسته',
|
'categories.tooltip.addSub': 'افزودن زیردسته',
|
||||||
'categories.tooltip.edit': 'ویرایش دستهبندی',
|
'categories.tooltip.edit': 'ویرایش دستهبندی',
|
||||||
|
'categories.tooltip.translate': 'ترجمه',
|
||||||
'categories.tooltip.variations': 'مدیریت تنوعها',
|
'categories.tooltip.variations': 'مدیریت تنوعها',
|
||||||
'categories.tooltip.technical': 'فرم اطلاعات فنی',
|
'categories.tooltip.technical': 'فرم اطلاعات فنی',
|
||||||
'categories.tooltip.options': 'گزینههای دستهبندی',
|
'categories.tooltip.options': 'گزینههای دستهبندی',
|
||||||
@@ -4677,6 +4744,29 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'common.codeSent': 'کد تأیید به {phone} ارسال شد',
|
'common.codeSent': 'کد تأیید به {phone} ارسال شد',
|
||||||
'common.breadcrumb': 'مسیر صفحه',
|
'common.breadcrumb': 'مسیر صفحه',
|
||||||
'common.overview': 'نمای کلی',
|
'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>> = {
|
const dictionaries: Record<DashboardLocale, Record<MessageKey, string>> = {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
--primary-dark: #2563eb;
|
--primary-dark: #2563eb;
|
||||||
--primary-rgb: 59 130 246;
|
--primary-rgb: 59 130 246;
|
||||||
--primary-dark-rgb: 37 99 235;
|
--primary-dark-rgb: 37 99 235;
|
||||||
|
--count-badge: #ee3552;
|
||||||
|
--count-badge-rgb: 238 53 82;
|
||||||
--chart-accent: #06b6d4;
|
--chart-accent: #06b6d4;
|
||||||
--chart-accent-dark: #0891b2;
|
--chart-accent-dark: #0891b2;
|
||||||
--chart-accent-rgb: 6 182 212;
|
--chart-accent-rgb: 6 182 212;
|
||||||
|
|||||||
@@ -515,6 +515,8 @@
|
|||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.25);
|
border-top: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
/* Primary/next (last child) stays on the physical left */
|
||||||
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghostBtn,
|
.ghostBtn,
|
||||||
|
|||||||
@@ -201,6 +201,8 @@
|
|||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
/* Main/submit button (last child) stays on the physical left */
|
||||||
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancelBtn {
|
.cancelBtn {
|
||||||
|
|||||||
@@ -185,6 +185,8 @@
|
|||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
/* Main/save button stays on the physical left */
|
||||||
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saveBtn {
|
.saveBtn {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { FolderTree, Plus, Sparkles } from 'lucide-react'
|
import { FolderTree, Globe, Plus, Sparkles } from 'lucide-react'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import { CategoryModal } from '../components/CategoryModal'
|
import { CategoryModal } from '../components/CategoryModal'
|
||||||
import { CategoryAiPromptModal } from '../components/CategoryAiPromptModal'
|
import { CategoryAiPromptModal } from '../components/CategoryAiPromptModal'
|
||||||
import { CategoryTree } from '../components/CategoryTree'
|
import { CategoryTree } from '../components/CategoryTree'
|
||||||
|
import { TranslationModal } from '../components/TranslationModal'
|
||||||
|
import { TranslateAllLanguageModal } from '../components/TranslateAllLanguageModal'
|
||||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||||
import { EmptyState } from '../components/EmptyState'
|
import { EmptyState } from '../components/EmptyState'
|
||||||
import { PageTitle } from '../components/PageTitle'
|
import { PageTitle } from '../components/PageTitle'
|
||||||
@@ -14,6 +16,7 @@ import { TechnicalFormAiPromptModal } from '../components/TechnicalFormAiPromptM
|
|||||||
import type { Category, CategoryFormData } from '../types/category'
|
import type { Category, CategoryFormData } from '../types/category'
|
||||||
import type { Variation } from '../types/variation'
|
import type { Variation } from '../types/variation'
|
||||||
import type { TechnicalFormFieldDraft } from '../types/technicalForm'
|
import type { TechnicalFormFieldDraft } from '../types/technicalForm'
|
||||||
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||||
import {
|
import {
|
||||||
listCategoryVariations,
|
listCategoryVariations,
|
||||||
saveCategoryVariations,
|
saveCategoryVariations,
|
||||||
@@ -36,6 +39,9 @@ import {
|
|||||||
updateProductCategory,
|
updateProductCategory,
|
||||||
} from '../services/productCategoryService'
|
} from '../services/productCategoryService'
|
||||||
import { useT } from '../i18n/useT'
|
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 pageStyles from '../components/PageContent.module.css'
|
||||||
import aiStyles from '../styles/ai.module.css'
|
import aiStyles from '../styles/ai.module.css'
|
||||||
import styles from './CategoriesPage.module.css'
|
import styles from './CategoriesPage.module.css'
|
||||||
@@ -43,6 +49,8 @@ import styles from './CategoriesPage.module.css'
|
|||||||
export function CategoriesPage() {
|
export function CategoriesPage() {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
|
const { enabledModules } = useTenantBranding()
|
||||||
|
const isMultiLanguageEnabled = hasBusinessModule(enabledModules, 'multilanguage_data')
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
@@ -74,7 +82,13 @@ export function CategoriesPage() {
|
|||||||
const [technicalError, setTechnicalError] = useState('')
|
const [technicalError, setTechnicalError] = useState('')
|
||||||
const [aiModalOpen, setAiModalOpen] = useState(false)
|
const [aiModalOpen, setAiModalOpen] = useState(false)
|
||||||
const [aiGenerating, setAiGenerating] = 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 [techAiPromptOpen, setTechAiPromptOpen] = useState(false)
|
||||||
|
const [translateTarget, setTranslateTarget] = useState<Category | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -82,6 +96,47 @@ export function CategoriesPage() {
|
|||||||
return () => controller.abort()
|
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) {
|
async function loadCategories(signal?: AbortSignal) {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
@@ -141,7 +196,11 @@ export function CategoriesPage() {
|
|||||||
setCategories((prev) =>
|
setCategories((prev) =>
|
||||||
prev.map((category) =>
|
prev.map((category) =>
|
||||||
category.id === editingCategory.id
|
category.id === editingCategory.id
|
||||||
? mapProductCategoryToUi(result.category)
|
? {
|
||||||
|
...mapProductCategoryToUi(result.category),
|
||||||
|
variationCount: category.variationCount,
|
||||||
|
translationCount: category.translationCount ?? 0,
|
||||||
|
}
|
||||||
: category,
|
: 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() {
|
async function handleSaveVariations() {
|
||||||
if (!variationsTarget) return
|
if (!variationsTarget) return
|
||||||
|
|
||||||
@@ -467,6 +563,11 @@ export function CategoriesPage() {
|
|||||||
const category = categories.find((item) => item.id === id)
|
const category = categories.find((item) => item.id === id)
|
||||||
if (category) openEditModal(category)
|
if (category) openEditModal(category)
|
||||||
}}
|
}}
|
||||||
|
onTranslate={(id) => {
|
||||||
|
if (!isMultiLanguageEnabled) return
|
||||||
|
const category = categories.find((item) => item.id === id)
|
||||||
|
if (category) setTranslateTarget(category)
|
||||||
|
}}
|
||||||
onRemove={(id) => {
|
onRemove={(id) => {
|
||||||
const category = categories.find((item) => item.id === id)
|
const category = categories.find((item) => item.id === id)
|
||||||
if (category) setDeleteTarget(category)
|
if (category) setDeleteTarget(category)
|
||||||
@@ -570,15 +671,86 @@ export function CategoriesPage() {
|
|||||||
isRunning={aiGenerating}
|
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}>
|
<div className={styles.fabDock}>
|
||||||
|
<div
|
||||||
|
ref={aiFabGroupRef}
|
||||||
|
className={aiStyles.aiFabGroup}
|
||||||
|
data-open={aiMenuOpen ? 'true' : undefined}
|
||||||
|
onMouseEnter={openAiFabMenu}
|
||||||
|
onMouseLeave={scheduleCloseAiFabMenu}
|
||||||
|
>
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={aiStyles.aiFabStrip}
|
className={aiStyles.aiFabStrip}
|
||||||
onClick={() => setAiModalOpen(true)}
|
aria-expanded={aiMenuOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label={t('categories.aiMenu')}
|
||||||
|
onClick={() => setAiMenuOpen((open) => !open)}
|
||||||
|
disabled={translatingAll || aiGenerating}
|
||||||
>
|
>
|
||||||
<Sparkles size={18} />
|
<Sparkles size={18} />
|
||||||
<span>{t('categories.addByAi')}</span>
|
<span>{t('categories.aiMenu')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.addFab}
|
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 { 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 { EmptyState, ListEmptyState } from '../components/EmptyState'
|
||||||
import { ProductCard } from '../components/ProductCard'
|
import { ProductCard } from '../components/ProductCard'
|
||||||
import { AddProductByAiModal } from '../components/AddProductByAiModal'
|
import { AddProductByAiModal } from '../components/AddProductByAiModal'
|
||||||
@@ -9,16 +9,23 @@ import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
|||||||
import { ProductVariantsModal } from '../components/ProductVariantsModal'
|
import { ProductVariantsModal } from '../components/ProductVariantsModal'
|
||||||
import { ProductTechnicalInfoModal } from '../components/ProductTechnicalInfoModal'
|
import { ProductTechnicalInfoModal } from '../components/ProductTechnicalInfoModal'
|
||||||
import { ProductCommentsModal } from '../components/ProductCommentsModal'
|
import { ProductCommentsModal } from '../components/ProductCommentsModal'
|
||||||
|
import { TranslationModal } from '../components/TranslationModal'
|
||||||
|
import { TranslateAllLanguageModal } from '../components/TranslateAllLanguageModal'
|
||||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||||
import { PageTitle } from '../components/PageTitle'
|
import { PageTitle } from '../components/PageTitle'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
|
import { translateAllProducts } from '../services/translationService'
|
||||||
|
import type { TranslationLocale } from '../types/translation'
|
||||||
import {
|
import {
|
||||||
deleteProduct,
|
deleteProduct,
|
||||||
listProducts,
|
listProducts,
|
||||||
PRODUCTS_PER_PAGE,
|
PRODUCTS_PER_PAGE,
|
||||||
} from '../services/productService'
|
} from '../services/productService'
|
||||||
import type { Product } from '../types/product'
|
import type { Product } from '../types/product'
|
||||||
|
import { hasBusinessModule } from '../utils/businessModules'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import filterStyles from '../components/ListFiltersPanel.module.css'
|
import filterStyles from '../components/ListFiltersPanel.module.css'
|
||||||
import styles from './MyProductsPage.module.css'
|
import styles from './MyProductsPage.module.css'
|
||||||
@@ -34,6 +41,9 @@ function readListQuery(searchParams: URLSearchParams) {
|
|||||||
export function MyProductsPage() {
|
export function MyProductsPage() {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const { enabledModules } = useTenantBranding()
|
||||||
|
const isMultiLanguageEnabled = hasBusinessModule(enabledModules, 'multilanguage_data')
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const { name: appliedName, page: currentPage } = readListQuery(searchParams)
|
const { name: appliedName, page: currentPage } = readListQuery(searchParams)
|
||||||
|
|
||||||
@@ -57,6 +67,12 @@ export function MyProductsPage() {
|
|||||||
categoryId: string
|
categoryId: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [aiModalOpen, setAiModalOpen] = useState(false)
|
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 [draftName, setDraftName] = useState(appliedName)
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(totalProducts / PRODUCTS_PER_PAGE))
|
const totalPages = Math.max(1, Math.ceil(totalProducts / PRODUCTS_PER_PAGE))
|
||||||
@@ -71,6 +87,47 @@ export function MyProductsPage() {
|
|||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
}, [currentPage, appliedName])
|
}, [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) {
|
function writeListQuery(name: string, page: number) {
|
||||||
const next = new URLSearchParams()
|
const next = new URLSearchParams()
|
||||||
if (name) next.set('q', name)
|
if (name) next.set('q', name)
|
||||||
@@ -109,6 +166,48 @@ export function MyProductsPage() {
|
|||||||
navigate(`/products/edit/${id}`)
|
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) {
|
function handleComments(id: string) {
|
||||||
const product = products.find((p) => p.id === id)
|
const product = products.find((p) => p.id === id)
|
||||||
if (product) {
|
if (product) {
|
||||||
@@ -278,6 +377,7 @@ export function MyProductsPage() {
|
|||||||
commentCount={commentCounts[product.id] ?? product.commentCount}
|
commentCount={commentCounts[product.id] ?? product.commentCount}
|
||||||
variantCount={variantCounts[product.id] ?? product.variantCount ?? 0}
|
variantCount={variantCounts[product.id] ?? product.variantCount ?? 0}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
|
onTranslate={handleTranslate}
|
||||||
onQuickInfo={() => {}}
|
onQuickInfo={() => {}}
|
||||||
onComments={handleComments}
|
onComments={handleComments}
|
||||||
onTechnicalInfo={handleTechnicalInfo}
|
onTechnicalInfo={handleTechnicalInfo}
|
||||||
@@ -361,15 +461,88 @@ export function MyProductsPage() {
|
|||||||
onCreated={(productId) => navigate(`/products/edit/${productId}`)}
|
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}>
|
<div className={styles.fabDock}>
|
||||||
|
<div
|
||||||
|
ref={aiFabGroupRef}
|
||||||
|
className={aiStyles.aiFabGroup}
|
||||||
|
data-open={aiMenuOpen ? 'true' : undefined}
|
||||||
|
onMouseEnter={openAiFabMenu}
|
||||||
|
onMouseLeave={scheduleCloseAiFabMenu}
|
||||||
|
>
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={aiStyles.aiFabStrip}
|
className={aiStyles.aiFabStrip}
|
||||||
onClick={() => setAiModalOpen(true)}
|
aria-expanded={aiMenuOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label={t('products.list.aiMenu')}
|
||||||
|
onClick={() => setAiMenuOpen((open) => !open)}
|
||||||
|
disabled={translatingAll}
|
||||||
>
|
>
|
||||||
<Sparkles size={18} />
|
<Sparkles size={18} />
|
||||||
<span>{t('products.list.addByAi')}</span>
|
<span>{t('products.list.aiMenu')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<Link to="/products/new" className={styles.addFab} aria-label={t('products.list.addNew')}>
|
<Link to="/products/new" className={styles.addFab} aria-label={t('products.list.addNew')}>
|
||||||
<Plus size={24} />
|
<Plus size={24} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -99,6 +99,8 @@
|
|||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
/* Main/save button stays on the physical left */
|
||||||
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saveBtn {
|
.saveBtn {
|
||||||
|
|||||||
@@ -164,6 +164,8 @@
|
|||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
/* Main/save button stays on the physical left */
|
||||||
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saveBtn {
|
.saveBtn {
|
||||||
|
|||||||
@@ -103,6 +103,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
|
/* Main/save button stays on the physical left */
|
||||||
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saveBtn {
|
.saveBtn {
|
||||||
|
|||||||
@@ -86,5 +86,6 @@ export function mapProductCategoryToUi(category: ProductCategory) {
|
|||||||
description: category.description ?? '',
|
description: category.description ?? '',
|
||||||
parentId: category.parentId,
|
parentId: category.parentId,
|
||||||
variationCount: category.variationCount ?? 0,
|
variationCount: category.variationCount ?? 0,
|
||||||
|
translationCount: category.translationCount ?? 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface ProductApi {
|
|||||||
galleryMediaIds: string[]
|
galleryMediaIds: string[]
|
||||||
commentCount: number
|
commentCount: number
|
||||||
variantCount: number
|
variantCount: number
|
||||||
|
translationCount?: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
@@ -99,6 +100,7 @@ export function mapProductApiToUi(product: ProductApi): Product {
|
|||||||
status: product.status === 'published' ? 'active' : 'draft',
|
status: product.status === 'published' ? 'active' : 'draft',
|
||||||
commentCount: product.commentCount,
|
commentCount: product.commentCount,
|
||||||
variantCount: product.variantCount ?? 0,
|
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),
|
.aiBtn:hover:not(:disabled),
|
||||||
.aiFabStrip:hover {
|
.aiFabStrip:hover:not(:disabled) {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 6px 20px rgba(67, 56, 202, 0.34),
|
0 6px 20px rgba(67, 56, 202, 0.34),
|
||||||
0 0 32px rgba(236, 72, 153, 0.22);
|
0 0 32px rgba(236, 72, 153, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aiBtn:disabled {
|
.aiBtn:disabled,
|
||||||
|
.aiFabStrip:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
animation-play-state: paused;
|
animation-play-state: paused;
|
||||||
@@ -85,11 +86,16 @@
|
|||||||
0 0 32px rgba(236, 72, 153, 0.18);
|
0 0 32px rgba(236, 72, 153, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aiPromptField textarea:focus,
|
.aiPromptField textarea.aiPromptTextarea,
|
||||||
textarea.aiPromptTextarea:focus {
|
.aiPromptField textarea,
|
||||||
|
textarea.aiPromptTextarea {
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
|
background-color: var(--surface, var(--elevated-surface));
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(var(--elevated-surface), var(--elevated-surface)),
|
linear-gradient(
|
||||||
|
var(--surface, var(--elevated-surface)),
|
||||||
|
var(--surface, var(--elevated-surface))
|
||||||
|
),
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
#ff375f 0%,
|
#ff375f 0%,
|
||||||
@@ -110,6 +116,153 @@ textarea.aiPromptTextarea:focus {
|
|||||||
0 0 16px rgba(255, 55, 95, 0.32),
|
0 0 16px rgba(255, 55, 95, 0.32),
|
||||||
0 0 28px rgba(10, 132, 255, 0.24),
|
0 0 28px rgba(10, 132, 255, 0.24),
|
||||||
0 0 42px rgba(191, 90, 242, 0.18);
|
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) {
|
@media (max-width: 768px) {
|
||||||
@@ -122,4 +275,12 @@ textarea.aiPromptTextarea:focus {
|
|||||||
.aiFabStrip span {
|
.aiFabStrip span {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aiFabMenu {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiFabMenuItem {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface Category {
|
|||||||
description: string
|
description: string
|
||||||
parentId: string | null
|
parentId: string | null
|
||||||
variationCount: number
|
variationCount: number
|
||||||
|
translationCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryFormData {
|
export interface CategoryFormData {
|
||||||
@@ -30,6 +31,7 @@ export interface ProductCategory {
|
|||||||
sortOrder: number
|
sortOrder: number
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
variationCount?: number
|
variationCount?: number
|
||||||
|
translationCount?: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ export interface Product {
|
|||||||
status: 'active' | 'draft'
|
status: 'active' | 'draft'
|
||||||
commentCount: number
|
commentCount: number
|
||||||
variantCount?: 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',
|
'instructions',
|
||||||
'workshops',
|
'workshops',
|
||||||
'finance',
|
'finance',
|
||||||
|
'multilanguage_data',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
|
/** 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.
|
* enabled except opt-in modules. Customer modules stay opt-in.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
|
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] = [
|
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
||||||
'orders_30d',
|
'orders_30d',
|
||||||
|
|||||||
@@ -494,6 +494,22 @@
|
|||||||
border-top: 1px solid rgba(148, 163, 184, 0.28);
|
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 {
|
.modulesCharts .field select {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
|
|||||||
@@ -2087,6 +2087,7 @@ export function BusinessesPage() {
|
|||||||
: 'Modules'
|
: 'Modules'
|
||||||
}
|
}
|
||||||
onClose={closeModules}
|
onClose={closeModules}
|
||||||
|
wide
|
||||||
>
|
>
|
||||||
{modulesError ? (
|
{modulesError ? (
|
||||||
<p className={styles.alertError} role="alert">
|
<p className={styles.alertError} role="alert">
|
||||||
@@ -2141,6 +2142,7 @@ export function BusinessesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.modulesCharts}>
|
<div className={styles.modulesCharts}>
|
||||||
<p className={styles.entityGroupLabel}>Home charts</p>
|
<p className={styles.entityGroupLabel}>Home charts</p>
|
||||||
|
<div className={styles.modulesChartsRow}>
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label htmlFor="modules-chart-1">Chart 1</label>
|
<label htmlFor="modules-chart-1">Chart 1</label>
|
||||||
<select
|
<select
|
||||||
@@ -2180,6 +2182,7 @@ export function BusinessesPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div style={{ height: 12 }} />
|
<div style={{ height: 12 }} />
|
||||||
<div className={styles.actionsRow}>
|
<div className={styles.actionsRow}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
|||||||
'instructions',
|
'instructions',
|
||||||
'workshops',
|
'workshops',
|
||||||
'finance',
|
'finance',
|
||||||
|
'multilanguage_data',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
|
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
|
||||||
@@ -38,6 +39,7 @@ export const BUSINESS_DASHBOARD_MODULE_LABELS: Record<
|
|||||||
instructions: 'Instructions',
|
instructions: 'Instructions',
|
||||||
workshops: 'Workshops',
|
workshops: 'Workshops',
|
||||||
finance: 'Finance',
|
finance: 'Finance',
|
||||||
|
multilanguage_data: 'MultiLanguage Data',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CUSTOMER_MODULE_LABELS: Record<CustomerModuleId, string> = {
|
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.
|
* enabled except opt-in modules. Customer modules stay opt-in.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
|
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] = [
|
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
||||||
'orders_30d',
|
'orders_30d',
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ Products accept optional `brandId` on create/update.
|
|||||||
- `GET/POST/PATCH/DELETE /businesses/:businessId/products`
|
- `GET/POST/PATCH/DELETE /businesses/:businessId/products`
|
||||||
- `POST /businesses/:businessId/products/ai-create` — AI-generated product draft
|
- `POST /businesses/:businessId/products/ai-create` — AI-generated product draft
|
||||||
- `GET /businesses/:businessId/products/:productId`
|
- `GET /businesses/:businessId/products/:productId`
|
||||||
|
- Translations (EN/AR, stored in `entity_translations`): `GET .../products/:id/translations`, `POST .../translations/:locale/ai`, `PUT .../translations/:locale`. Same three routes on categories. Dashboard category list includes `translationCount`. Bulk: `POST .../categories/translations/ai-all` and `POST .../products/translations/ai-all` (missing EN/AR only; skips existing). Business dashboard shows translation controls only when the `multilanguage_data` module is enabled for that business.
|
||||||
|
|
||||||
### Contact submissions
|
### Contact submissions
|
||||||
- `GET /businesses/:businessId/contact-submissions` — paginated list
|
- `GET /businesses/:businessId/contact-submissions` — paginated list
|
||||||
|
|||||||
Reference in New Issue
Block a user