From 2a32c46b2e3490f066102b7c24d25c6235277379 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Fri, 4 Sep 2026 09:51:11 +0330 Subject: [PATCH] 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 --- .../src/components/BrandModal.module.css | 2 + .../src/components/CategoryModal.module.css | 2 + .../src/components/CategoryRow.module.css | 23 +- apps/business/src/components/CategoryRow.tsx | 27 +- apps/business/src/components/CategoryTree.tsx | 4 + .../ConfirmBulkAddStoreItemsModal.module.css | 2 + .../components/ConfirmDeleteModal.module.css | 2 + .../business/src/components/Header.module.css | 4 +- .../src/components/InvoiceForm.module.css | 2 + .../LanguageLocaleOptions.module.css | 90 +++++ .../src/components/LanguageLocaleOptions.tsx | 52 +++ .../PickStoreSpecialItemsModal.module.css | 2 + .../src/components/ProductCard.module.css | 12 +- apps/business/src/components/ProductCard.tsx | 27 ++ .../components/ProductDetailsTabs.module.css | 2 +- .../components/ShoppingCartModal.module.css | 2 + .../TranslateAllLanguageModal.module.css | 35 ++ .../components/TranslateAllLanguageModal.tsx | 134 ++++++++ .../components/TranslationModal.module.css | 63 ++++ .../src/components/TranslationModal.tsx | 319 ++++++++++++++++++ .../src/components/VariationsModal.module.css | 2 + apps/business/src/i18n/messages.ts | 90 +++++ apps/business/src/index.css | 2 + .../pages/AddCustomerProductPage.module.css | 2 + .../src/pages/AddNewProductPage.module.css | 2 + .../src/pages/BusinessProfilePage.module.css | 2 + apps/business/src/pages/CategoriesPage.tsx | 192 ++++++++++- apps/business/src/pages/MyProductsPage.tsx | 191 ++++++++++- .../business/src/pages/ProfilePage.module.css | 2 + .../src/pages/SettingsPage.module.css | 2 + .../src/pages/StoreSettingsPage.module.css | 2 + .../src/services/productCategoryService.ts | 1 + apps/business/src/services/productService.ts | 2 + .../src/services/translationService.ts | 96 ++++++ apps/business/src/styles/ai.module.css | 171 +++++++++- apps/business/src/types/category.ts | 2 + apps/business/src/types/product.ts | 1 + apps/business/src/types/translation.ts | 29 ++ apps/business/src/utils/businessModules.ts | 5 +- .../src/pages/BusinessesPage.module.css | 16 + apps/super-admin/src/pages/BusinessesPage.tsx | 77 +++-- apps/super-admin/src/utils/businessModules.ts | 6 +- docs/PROJECT_CONTEXT.md | 1 + 43 files changed, 1624 insertions(+), 78 deletions(-) create mode 100644 apps/business/src/components/LanguageLocaleOptions.module.css create mode 100644 apps/business/src/components/LanguageLocaleOptions.tsx create mode 100644 apps/business/src/components/TranslateAllLanguageModal.module.css create mode 100644 apps/business/src/components/TranslateAllLanguageModal.tsx create mode 100644 apps/business/src/components/TranslationModal.module.css create mode 100644 apps/business/src/components/TranslationModal.tsx create mode 100644 apps/business/src/services/translationService.ts create mode 100644 apps/business/src/types/translation.ts diff --git a/apps/business/src/components/BrandModal.module.css b/apps/business/src/components/BrandModal.module.css index 92f5fc5..29f21a2 100644 --- a/apps/business/src/components/BrandModal.module.css +++ b/apps/business/src/components/BrandModal.module.css @@ -104,6 +104,8 @@ justify-content: flex-end; gap: 10px; padding-top: 4px; + /* Main/submit button (last child) stays on the physical left */ + direction: rtl; } .cancelBtn { diff --git a/apps/business/src/components/CategoryModal.module.css b/apps/business/src/components/CategoryModal.module.css index 80e9345..d068f94 100644 --- a/apps/business/src/components/CategoryModal.module.css +++ b/apps/business/src/components/CategoryModal.module.css @@ -162,6 +162,8 @@ justify-content: flex-end; gap: 10px; padding-top: 4px; + /* Main/submit button (last child) stays on the physical left */ + direction: rtl; } .cancelBtn { diff --git a/apps/business/src/components/CategoryRow.module.css b/apps/business/src/components/CategoryRow.module.css index e13636e..8f46772 100644 --- a/apps/business/src/components/CategoryRow.module.css +++ b/apps/business/src/components/CategoryRow.module.css @@ -139,7 +139,7 @@ font-size: 9px; font-weight: 700; color: white; - background: var(--primary); + background: var(--count-badge); border-radius: 50px; line-height: 1; pointer-events: none; @@ -158,7 +158,26 @@ font-size: 9px; font-weight: 700; color: white; - background: #0d9488; + background: var(--count-badge); + border-radius: 50px; + line-height: 1; + pointer-events: none; +} + +.translateBadge { + position: absolute; + top: -4px; + right: -4px; + min-width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + font-size: 9px; + font-weight: 700; + color: white; + background: var(--count-badge); border-radius: 50px; line-height: 1; pointer-events: none; diff --git a/apps/business/src/components/CategoryRow.tsx b/apps/business/src/components/CategoryRow.tsx index 022e0d2..bbf418b 100644 --- a/apps/business/src/components/CategoryRow.tsx +++ b/apps/business/src/components/CategoryRow.tsx @@ -1,8 +1,10 @@ -import { FolderPlus, Layers, Trash2, ChevronRight, ClipboardList, Pencil } from 'lucide-react' +import { FolderPlus, Globe, Layers, Trash2, ChevronRight, ClipboardList, Pencil } from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import type { Category } from '../types/category' import { useT } from '../i18n/useT' import { Tooltip } from './Tooltip' +import { useTenantBranding } from '../context/TenantBrandingContext' +import { hasBusinessModule } from '../utils/businessModules' import styles from './CategoryRow.module.css' interface CategoryRowProps { @@ -17,6 +19,7 @@ interface CategoryRowProps { onVariations: (id: string) => void onTechnicalForm: (id: string) => void onEdit: (id: string) => void + onTranslate: (id: string) => void onRemove: (id: string) => void } @@ -32,11 +35,15 @@ export function CategoryRow({ onVariations, onTechnicalForm, onEdit, + onTranslate, onRemove, }: CategoryRowProps) { const t = useT() const { locale } = useLocale() const isFa = locale === 'fa' + const { enabledModules } = useTenantBranding() + const showTranslationControls = hasBusinessModule(enabledModules, 'multilanguage_data') + const translationCount = category.translationCount ?? 0 const primaryName = isFa ? category.nameFa || category.nameEn : category.nameEn const secondaryName = isFa ? category.nameFa @@ -121,6 +128,24 @@ export function CategoryRow({ )} + {showTranslationControls ? ( + + + + ) : null} + ) + })} + + ) +} diff --git a/apps/business/src/components/PickStoreSpecialItemsModal.module.css b/apps/business/src/components/PickStoreSpecialItemsModal.module.css index 2f722a2..d24fa6e 100644 --- a/apps/business/src/components/PickStoreSpecialItemsModal.module.css +++ b/apps/business/src/components/PickStoreSpecialItemsModal.module.css @@ -25,6 +25,8 @@ gap: 10px; padding-top: 16px; margin-top: 4px; + /* Main button (last child) stays on the physical left */ + direction: rtl; } .cancelBtn, diff --git a/apps/business/src/components/ProductCard.module.css b/apps/business/src/components/ProductCard.module.css index 483a7c0..e5a8e74 100644 --- a/apps/business/src/components/ProductCard.module.css +++ b/apps/business/src/components/ProductCard.module.css @@ -201,7 +201,8 @@ } .variantBadge, -.commentBadge { +.commentBadge, +.translateBadge { position: absolute; top: -4px; right: -4px; @@ -214,19 +215,12 @@ font-size: 9px; font-weight: 700; color: white; + background: var(--count-badge); border-radius: 50px; line-height: 1; pointer-events: none; } -.variantBadge { - background: var(--primary); -} - -.commentBadge { - background: #ef4444; -} - .controls button:hover { background: rgba(var(--primary-rgb) / 0.1); color: var(--primary); diff --git a/apps/business/src/components/ProductCard.tsx b/apps/business/src/components/ProductCard.tsx index 62588bf..a8641ae 100644 --- a/apps/business/src/components/ProductCard.tsx +++ b/apps/business/src/components/ProductCard.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { Pencil, + Globe, Info, Trash2, Layers, @@ -13,6 +14,8 @@ import { useLocale } from '@meshkee/dashboard-ui' import type { Product } from '../types/product' import { useT } from '../i18n/useT' import { Tooltip } from './Tooltip' +import { useTenantBranding } from '../context/TenantBrandingContext' +import { hasBusinessModule } from '../utils/businessModules' import { formatProductCategoryChip } from '../services/productService' import styles from './ProductCard.module.css' @@ -21,6 +24,7 @@ interface ProductCardProps { commentCount: number variantCount: number onEdit: (id: string) => void + onTranslate: (id: string) => void onQuickInfo: (id: string) => void onComments: (id: string) => void onTechnicalInfo: (id: string) => void @@ -33,6 +37,7 @@ export function ProductCard({ commentCount, variantCount, onEdit, + onTranslate, onQuickInfo, onComments, onTechnicalInfo, @@ -42,6 +47,9 @@ export function ProductCard({ const t = useT() const { locale } = useLocale() const isFa = locale === 'fa' + const { enabledModules } = useTenantBranding() + const showTranslationControls = hasBusinessModule(enabledModules, 'multilanguage_data') + const translationCount = product.translationCount ?? 0 const primaryName = isFa ? product.nameFa || product.nameEn : product.nameEn const secondaryName = isFa ? product.nameFa @@ -103,6 +111,25 @@ export function ProductCard({ + {showTranslationControls ? ( + + + + ) : null} + + +
+ + +
+ + +
+
+ + , + document.body, + ) +} diff --git a/apps/business/src/components/TranslationModal.module.css b/apps/business/src/components/TranslationModal.module.css new file mode 100644 index 0000000..c7c02d7 --- /dev/null +++ b/apps/business/src/components/TranslationModal.module.css @@ -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; +} diff --git a/apps/business/src/components/TranslationModal.tsx b/apps/business/src/components/TranslationModal.tsx new file mode 100644 index 0000000..b33b964 --- /dev/null +++ b/apps/business/src/components/TranslationModal.tsx @@ -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('en') + const [loading, setLoading] = useState(false) + const [translating, setTranslating] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [drafts, setDrafts] = useState>>({}) + + 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> = {} + 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( +
!translating && !saving && onClose()} + > +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="translation-modal-title" + lang={isFa ? 'fa' : 'en'} + dir={isFa ? 'rtl' : 'ltr'} + > +
+

+ {t( + entityType === 'product' + ? 'translations.title.product' + : 'translations.title.category', + )} + {entityName ? · {entityName} : null} +

+ +
+ +
+
+ { + setTab(locale) + setError('') + }} + /> +
+ + {error ? ( +

+ {error} +

+ ) : null} + + {loading ? ( +

{t('translations.loading')}

+ ) : !hasTranslation ? ( +
+

{t('translations.emptyHint')}

+ +
+ ) : ( +
void handleSave(e)} + dir={tab === 'ar' ? 'rtl' : 'ltr'} + lang={tab} + > + {entityType === 'category' ? ( + <> +
+ + updateField('name', e.target.value)} + disabled={busy} + /> +
+
+ +