diff --git a/client/src/App.jsx b/client/src/App.jsx
index 9ad83e4..f6acf08 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -35,6 +35,8 @@ const PublicPostReview = lazy(() => import('./pages/PublicPostReview'))
const Issues = lazy(() => import('./pages/Issues'))
const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit'))
const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker'))
+const Translations = lazy(() => import('./pages/Translations'))
+const PublicTranslationReview = lazy(() => import('./pages/PublicTranslationReview'))
const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
const ResetPassword = lazy(() => import('./pages/ResetPassword'))
@@ -295,6 +297,7 @@ function AppContent() {
} />
} />
} />
+ } />
: }>
} />
{hasModule('marketing') && <>
@@ -305,6 +308,7 @@ function AppContent() {
} />
} />
} />
+ } />
>}
{hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <>
} />
diff --git a/client/src/components/Sidebar.jsx b/client/src/components/Sidebar.jsx
index 4654af9..5545478 100644
--- a/client/src/components/Sidebar.jsx
+++ b/client/src/components/Sidebar.jsx
@@ -26,6 +26,7 @@ const moduleGroups = [
{ to: '/artefacts', icon: Palette, labelKey: 'nav.artefacts' },
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
{ to: '/brands', icon: Tag, labelKey: 'nav.brands' },
+ { to: '/translations', icon: Languages, labelKey: 'nav.translations' },
],
},
{
diff --git a/client/src/components/TranslationDetailPanel.jsx b/client/src/components/TranslationDetailPanel.jsx
new file mode 100644
index 0000000..39a9ea3
--- /dev/null
+++ b/client/src/components/TranslationDetailPanel.jsx
@@ -0,0 +1,558 @@
+import { useState, useEffect, useContext } from 'react'
+import { Plus, Copy, Check, ExternalLink, Trash2, Save, FileEdit, Languages, ShieldCheck, Globe } from 'lucide-react'
+import { AppContext } from '../App'
+import { useLanguage } from '../i18n/LanguageContext'
+import { api } from '../utils/api'
+import Modal from './Modal'
+import TabbedModal from './TabbedModal'
+import { useToast } from './ToastContainer'
+import ApproverMultiSelect from './ApproverMultiSelect'
+
+const STATUS_COLORS = {
+ draft: 'bg-surface-tertiary text-text-secondary',
+ pending_review: 'bg-amber-100 text-amber-700',
+ approved: 'bg-emerald-100 text-emerald-700',
+ rejected: 'bg-red-100 text-red-700',
+ revision_requested: 'bg-orange-100 text-orange-700',
+}
+
+const AVAILABLE_LANGUAGES = [
+ { code: 'AR', label: 'العربية' },
+ { code: 'EN', label: 'English' },
+ { code: 'FR', label: 'Français' },
+ { code: 'ID', label: 'Bahasa Indonesia' },
+]
+
+export default function TranslationDetailPanel({ translation, onClose, onUpdate, onDelete, assignableUsers = [] }) {
+ const { t } = useLanguage()
+ const { brands } = useContext(AppContext)
+ const toast = useToast()
+
+ const [editTitle, setEditTitle] = useState(translation.title || '')
+ const [editDescription, setEditDescription] = useState(translation.description || '')
+ const [editSourceContent, setEditSourceContent] = useState(translation.source_content || '')
+ const [editSourceLanguage, setEditSourceLanguage] = useState(translation.source_language || 'EN')
+ const [editApproverIds, setEditApproverIds] = useState(
+ translation.approvers?.map(a => String(a.id)) || (translation.approver_ids ? translation.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
+ )
+ const reviewUrl = translation.approval_token ? `${window.location.origin}/review-translation/${translation.approval_token}` : ''
+
+ const [activeTab, setActiveTab] = useState('details')
+ const [texts, setTexts] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [savingDraft, setSavingDraft] = useState(false)
+ const [submitting, setSubmitting] = useState(false)
+ const [copied, setCopied] = useState(false)
+ const [freshReviewUrl, setFreshReviewUrl] = useState('')
+
+ // Language add modal
+ const [showAddLang, setShowAddLang] = useState(false)
+ const [langForm, setLangForm] = useState({ language_code: '', content: '' })
+ const [savingLang, setSavingLang] = useState(false)
+
+ // Delete confirm
+ const [confirmDeleteTextId, setConfirmDeleteTextId] = useState(null)
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+
+ // Inline editing for translation texts
+ const [editingTextId, setEditingTextId] = useState(null)
+ const [editingContent, setEditingContent] = useState('')
+
+ useEffect(() => {
+ loadTexts()
+ }, [translation.Id])
+
+ useEffect(() => {
+ setEditTitle(translation.title || '')
+ setEditDescription(translation.description || '')
+ setEditSourceContent(translation.source_content || '')
+ setEditSourceLanguage(translation.source_language || 'EN')
+ setEditApproverIds(
+ translation.approvers?.map(a => String(a.id)) || (translation.approver_ids ? translation.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
+ )
+ }, [translation.Id])
+
+ const loadTexts = async () => {
+ try {
+ const res = await api.get(`/translations/${translation.Id}/texts`)
+ setTexts(Array.isArray(res) ? res : [])
+ } catch (err) {
+ console.error('Failed to load texts:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleSaveDraft = async () => {
+ if (!editTitle.trim()) {
+ toast.error(t('translations.titleRequired'))
+ return
+ }
+ setSavingDraft(true)
+ try {
+ await api.patch(`/translations/${translation.Id}`, {
+ title: editTitle,
+ description: editDescription,
+ source_content: editSourceContent,
+ source_language: editSourceLanguage,
+ approver_ids: editApproverIds.length > 0 ? editApproverIds.join(',') : null,
+ })
+ toast.success(t('translations.draftSaved'))
+ onUpdate()
+ } catch (err) {
+ toast.error(t('translations.failedSaveDraft'))
+ } finally {
+ setSavingDraft(false)
+ }
+ }
+
+ const handleFieldUpdate = async (field, value) => {
+ try {
+ await api.patch(`/translations/${translation.Id}`, { [field]: value || null })
+ toast.success(t('translations.updated'))
+ onUpdate()
+ } catch (err) {
+ toast.error(t('translations.failedUpdate'))
+ }
+ }
+
+ const handleAddTranslationText = async () => {
+ if (!langForm.language_code || !langForm.content) {
+ toast.error(t('translations.allFieldsRequired'))
+ return
+ }
+ setSavingLang(true)
+ try {
+ const lang = AVAILABLE_LANGUAGES.find(l => l.code === langForm.language_code)
+ await api.post(`/translations/${translation.Id}/texts`, {
+ language_code: langForm.language_code,
+ language_label: lang?.label || langForm.language_code,
+ content: langForm.content,
+ })
+ toast.success(t('translations.translationAdded'))
+ setShowAddLang(false)
+ setLangForm({ language_code: '', content: '' })
+ loadTexts()
+ } catch (err) {
+ toast.error(t('translations.failedAddTranslation'))
+ } finally {
+ setSavingLang(false)
+ }
+ }
+
+ const handleUpdateText = async (textId) => {
+ try {
+ const text = texts.find(t => t.Id === textId)
+ if (!text) return
+ await api.post(`/translations/${translation.Id}/texts`, {
+ language_code: text.language_code,
+ language_label: text.language_label,
+ content: editingContent,
+ })
+ toast.success(t('translations.updated'))
+ setEditingTextId(null)
+ loadTexts()
+ } catch (err) {
+ toast.error(t('translations.failedUpdate'))
+ }
+ }
+
+ const handleDeleteText = async (textId) => {
+ try {
+ await api.delete(`/translations/${translation.Id}/texts/${textId}`)
+ toast.success(t('translations.translationDeleted'))
+ loadTexts()
+ } catch (err) {
+ toast.error(t('translations.failedDeleteTranslation'))
+ }
+ }
+
+ const handleSubmitReview = async () => {
+ setSubmitting(true)
+ try {
+ const res = await api.post(`/translations/${translation.Id}/submit-review`)
+ setFreshReviewUrl(res.reviewUrl || res.data?.reviewUrl || '')
+ toast.success(t('translations.submittedForReview'))
+ onUpdate()
+ } catch (err) {
+ toast.error(t('translations.failedSubmitReview'))
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const copyReviewLink = () => {
+ const url = freshReviewUrl || reviewUrl
+ navigator.clipboard.writeText(url)
+ setCopied(true)
+ toast.success(t('translations.linkCopied'))
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ const handleDelete = async () => {
+ try {
+ await onDelete(translation.Id || translation.id || translation._id)
+ } catch (err) {
+ toast.error(t('translations.failedDelete'))
+ }
+ }
+
+ // Available languages for adding (exclude source + already added)
+ const usedCodes = new Set([translation.source_language, ...texts.map(t => t.language_code)])
+ const availableForAdd = AVAILABLE_LANGUAGES.filter(l => !usedCodes.has(l.code))
+
+ const tabs = [
+ { key: 'details', label: t('translations.details'), icon: FileEdit },
+ { key: 'translations', label: t('translations.translationTexts'), icon: Languages, badge: texts.length },
+ { key: 'review', label: t('translations.review'), icon: ShieldCheck },
+ ]
+
+ const currentReviewUrl = freshReviewUrl || reviewUrl
+
+ return (
+ <>
+
+
+
+ setEditTitle(e.target.value)}
+ className="text-lg font-bold text-text-primary bg-transparent border-none outline-none focus:ring-0 w-full"
+ placeholder={t('translations.titlePlaceholder')}
+ />
+
+
+
+ {translation.status?.replace('_', ' ')}
+
+
+ {AVAILABLE_LANGUAGES.find(l => l.code === editSourceLanguage)?.label || editSourceLanguage}
+
+ {translation.creator_name && (
+
+ {t('review.createdBy')} {translation.creator_name}
+
+ )}
+
+ >
+ }
+ tabs={tabs}
+ activeTab={activeTab}
+ onTabChange={setActiveTab}
+ footer={
+
+
+
+
+
+
+ }
+ >
+ {/* Details Tab */}
+ {activeTab === 'details' && (
+
+
+
{t('translations.sourceLanguage')}
+
+
+
+
+
{t('translations.sourceContent')}
+
+
+
+
{t('translations.descriptionLabel')}
+
+
+
+
+
{t('translations.brand')}
+
+
+
+
+
+
{t('translations.approversLabel')}
+
+
+
+ )}
+
+ {/* Translations Tab */}
+ {activeTab === 'translations' && (
+
+ {/* Source content reference */}
+
+
+
+
+ {t('translations.sourceContent')} — {AVAILABLE_LANGUAGES.find(l => l.code === translation.source_language)?.label || translation.source_language}
+
+
+
{translation.source_content}
+
+
+ {/* Add translation button */}
+
+
{t('translations.translationTexts')}
+ {availableForAdd.length > 0 && (
+
+ )}
+
+
+ {/* Translation texts list */}
+ {texts.length > 0 ? (
+
+ {texts.map(text => (
+
+
+
+ {text.language_label || text.language_code}
+ ({text.language_code})
+
+
+ {editingTextId === text.Id ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ {editingTextId === text.Id ? (
+
+ ))}
+
+ ) : (
+
+
+
{t('translations.noTranslationTexts')}
+
+ )}
+
+ )}
+
+ {/* Review Tab */}
+ {activeTab === 'review' && (
+
+ {['draft', 'revision_requested', 'rejected'].includes(translation.status) && (
+
+ )}
+
+ {currentReviewUrl && (
+
+
{t('translations.reviewLinkTitle')}
+
+
+
+
+
+
+
+
+ )}
+
+ {translation.feedback && (
+
+
{t('translations.feedbackTitle')}
+
{translation.feedback}
+
+ )}
+
+ {translation.status === 'approved' && translation.approved_by_name && (
+
+
{t('translations.approvedByLabel')} {translation.approved_by_name}
+ {translation.approved_at && (
+
+ {new Date(translation.approved_at).toLocaleString()}
+
+ )}
+
+ )}
+
+ {!['draft', 'revision_requested', 'rejected'].includes(translation.status) && !currentReviewUrl && !translation.feedback && !(translation.status === 'approved' && translation.approved_by_name) && (
+
+ {translation.status === 'pending_review'
+ ? t('translations.pendingReviewInfo')
+ : t('translations.noReviewInfo')}
+
+ )}
+
+ )}
+
+
+ {/* Add Translation Modal */}
+ setShowAddLang(false)} title={t('translations.addTranslation')} size="md">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Delete translation text confirm */}
+ setConfirmDeleteTextId(null)}
+ title={t('translations.deleteTranslationText')}
+ isConfirm
+ danger
+ onConfirm={() => { handleDeleteText(confirmDeleteTextId); setConfirmDeleteTextId(null) }}
+ confirmText={t('common.delete')}
+ >
+ {t('translations.deleteTranslationTextDesc')}
+
+
+ {/* Delete translation confirm */}
+ setShowDeleteConfirm(false)}
+ title={t('translations.deleteTranslation')}
+ isConfirm
+ danger
+ onConfirm={() => { handleDelete(); setShowDeleteConfirm(false) }}
+ confirmText={t('common.delete')}
+ >
+ {t('translations.deleteTranslationDesc')}
+
+ >
+ )
+}
diff --git a/client/src/i18n/ar.json b/client/src/i18n/ar.json
index e59f9ef..2006542 100644
--- a/client/src/i18n/ar.json
+++ b/client/src/i18n/ar.json
@@ -938,5 +938,88 @@
"posts.deleteLanguage": "حذف هذه اللغة؟",
"posts.deleteLanguageConfirm": "سيتم حذف محتوى اللغة من هذا الإصدار.",
"posts.media": "الوسائط",
- "posts.noMedia": "لم يتم رفع ملفات وسائط"
+ "posts.noMedia": "لم يتم رفع ملفات وسائط",
+
+ "nav.translations": "الترجمات",
+ "translations.title": "الترجمات",
+ "translations.subtitle": "إدارة ترجمات المحتوى مع سير عمل الموافقة",
+ "translations.newTranslation": "ترجمة جديدة",
+ "translations.createTranslation": "إنشاء ترجمة",
+ "translations.searchTranslations": "البحث في الترجمات...",
+ "translations.titleLabel": "العنوان",
+ "translations.titlePlaceholder": "مثال: ترجمة شعار الحملة",
+ "translations.sourceLanguage": "لغة المصدر",
+ "translations.sourceContent": "المحتوى الأصلي",
+ "translations.sourceContentPlaceholder": "أدخل المحتوى الأصلي المراد ترجمته...",
+ "translations.description": "الوصف",
+ "translations.descriptionLabel": "الوصف",
+ "translations.descriptionPlaceholder": "سياق أو ملاحظات حول هذه الترجمة...",
+ "translations.brand": "العلامة التجارية",
+ "translations.creator": "المنشئ",
+ "translations.approvers": "المراجعون",
+ "translations.approversLabel": "المراجعون",
+ "translations.status": "الحالة",
+ "translations.languagesLabel": "اللغات",
+ "translations.languagesCount": "لغات",
+ "translations.updated": "تم التحديث",
+ "translations.grid": "شبكة",
+ "translations.list": "قائمة",
+ "translations.allBrands": "جميع العلامات",
+ "translations.allStatuses": "جميع الحالات",
+ "translations.allCreators": "جميع المنشئين",
+ "translations.status.draft": "مسودة",
+ "translations.status.pendingReview": "بانتظار المراجعة",
+ "translations.status.approved": "موافق عليه",
+ "translations.status.rejected": "مرفوض",
+ "translations.status.revisionRequested": "طلب تعديل",
+ "translations.sortRecentlyUpdated": "آخر تحديث",
+ "translations.sortNewest": "الأحدث أولاً",
+ "translations.sortOldest": "الأقدم أولاً",
+ "translations.sortTitleAZ": "العنوان أ-ي",
+ "translations.noTranslations": "لم يتم العثور على ترجمات",
+ "translations.loadFailed": "فشل تحميل الترجمات",
+ "translations.titleRequired": "العنوان مطلوب",
+ "translations.sourceContentRequired": "المحتوى الأصلي مطلوب",
+ "translations.created": "تم إنشاء الترجمة!",
+ "translations.createFailed": "فشل إنشاء الترجمة",
+ "translations.creating": "جارٍ الإنشاء...",
+ "translations.deleted": "تم حذف الترجمة!",
+ "translations.deleteFailed": "فشل حذف الترجمة",
+ "translations.details": "التفاصيل",
+ "translations.translationTexts": "الترجمات",
+ "translations.review": "المراجعة",
+ "translations.draftSaved": "تم حفظ المسودة!",
+ "translations.failedSaveDraft": "فشل حفظ المسودة",
+ "translations.saveDraft": "حفظ المسودة",
+ "translations.saveDraftTooltip": "حفظ التغييرات على العنوان والوصف والمحتوى الأصلي",
+ "translations.savingDraft": "جارٍ الحفظ...",
+ "translations.updated": "تم التحديث!",
+ "translations.failedUpdate": "فشل التحديث",
+ "translations.addTranslation": "إضافة ترجمة",
+ "translations.translationAdded": "تمت إضافة الترجمة!",
+ "translations.failedAddTranslation": "فشل إضافة الترجمة",
+ "translations.translationDeleted": "تم حذف الترجمة!",
+ "translations.failedDeleteTranslation": "فشل حذف الترجمة",
+ "translations.noTranslationTexts": "لا توجد ترجمات بعد. أضف واحدة لكل لغة مستهدفة.",
+ "translations.allFieldsRequired": "اللغة والمحتوى مطلوبان",
+ "translations.languageLabel": "اللغة",
+ "translations.selectLanguage": "اختر لغة",
+ "translations.translatedContent": "المحتوى المترجم",
+ "translations.enterTranslatedContent": "أدخل المحتوى المترجم...",
+ "translations.deleteTranslation": "حذف الترجمة",
+ "translations.deleteTranslationDesc": "سيتم حذف هذه الترجمة وجميع نسخ اللغات نهائيًا.",
+ "translations.deleteTranslationText": "حذف نص الترجمة",
+ "translations.deleteTranslationTextDesc": "سيتم حذف ترجمة هذه اللغة.",
+ "translations.bulkDeleteDesc": "حذف الترجمات المحددة؟",
+ "translations.submitForReview": "تقديم للمراجعة",
+ "translations.submitting": "جارٍ التقديم...",
+ "translations.submittedForReview": "تم التقديم للمراجعة!",
+ "translations.failedSubmitReview": "فشل التقديم للمراجعة",
+ "translations.reviewLinkTitle": "رابط المراجعة",
+ "translations.linkCopied": "تم نسخ الرابط!",
+ "translations.feedbackTitle": "ملاحظات المراجع",
+ "translations.approvedByLabel": "وافق عليه",
+ "translations.pendingReviewInfo": "هذه الترجمة بانتظار المراجعة حاليًا.",
+ "translations.noReviewInfo": "لا توجد معلومات مراجعة متاحة.",
+ "translations.failedDelete": "فشل الحذف"
}
\ No newline at end of file
diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json
index 34695c8..ff12f43 100644
--- a/client/src/i18n/en.json
+++ b/client/src/i18n/en.json
@@ -938,5 +938,88 @@
"posts.deleteLanguage": "Delete this language?",
"posts.deleteLanguageConfirm": "This will remove the language content from this version.",
"posts.media": "Media",
- "posts.noMedia": "No media files uploaded"
+ "posts.noMedia": "No media files uploaded",
+
+ "nav.translations": "Translations",
+ "translations.title": "Translations",
+ "translations.subtitle": "Manage content translations with approval workflow",
+ "translations.newTranslation": "New Translation",
+ "translations.createTranslation": "Create Translation",
+ "translations.searchTranslations": "Search translations...",
+ "translations.titleLabel": "Title",
+ "translations.titlePlaceholder": "e.g. Campaign tagline translation",
+ "translations.sourceLanguage": "Source Language",
+ "translations.sourceContent": "Source Content",
+ "translations.sourceContentPlaceholder": "Enter the original content to translate...",
+ "translations.description": "Description",
+ "translations.descriptionLabel": "Description",
+ "translations.descriptionPlaceholder": "Context or notes about this translation...",
+ "translations.brand": "Brand",
+ "translations.creator": "Creator",
+ "translations.approvers": "Approvers",
+ "translations.approversLabel": "Approvers",
+ "translations.status": "Status",
+ "translations.languagesLabel": "Languages",
+ "translations.languagesCount": "languages",
+ "translations.updated": "Updated",
+ "translations.grid": "Grid",
+ "translations.list": "List",
+ "translations.allBrands": "All Brands",
+ "translations.allStatuses": "All Statuses",
+ "translations.allCreators": "All Creators",
+ "translations.status.draft": "Draft",
+ "translations.status.pendingReview": "Pending Review",
+ "translations.status.approved": "Approved",
+ "translations.status.rejected": "Rejected",
+ "translations.status.revisionRequested": "Revision Requested",
+ "translations.sortRecentlyUpdated": "Recently Updated",
+ "translations.sortNewest": "Newest First",
+ "translations.sortOldest": "Oldest First",
+ "translations.sortTitleAZ": "Title A-Z",
+ "translations.noTranslations": "No translations found",
+ "translations.loadFailed": "Failed to load translations",
+ "translations.titleRequired": "Title is required",
+ "translations.sourceContentRequired": "Source content is required",
+ "translations.created": "Translation created!",
+ "translations.createFailed": "Failed to create translation",
+ "translations.creating": "Creating...",
+ "translations.deleted": "Translation deleted!",
+ "translations.deleteFailed": "Failed to delete translation",
+ "translations.details": "Details",
+ "translations.translationTexts": "Translations",
+ "translations.review": "Review",
+ "translations.draftSaved": "Draft saved!",
+ "translations.failedSaveDraft": "Failed to save draft",
+ "translations.saveDraft": "Save Draft",
+ "translations.saveDraftTooltip": "Save changes to title, description, and source content",
+ "translations.savingDraft": "Saving...",
+ "translations.updated": "Updated!",
+ "translations.failedUpdate": "Failed to update",
+ "translations.addTranslation": "Add Translation",
+ "translations.translationAdded": "Translation added!",
+ "translations.failedAddTranslation": "Failed to add translation",
+ "translations.translationDeleted": "Translation deleted!",
+ "translations.failedDeleteTranslation": "Failed to delete translation",
+ "translations.noTranslationTexts": "No translations yet. Add one for each target language.",
+ "translations.allFieldsRequired": "Language and content are required",
+ "translations.languageLabel": "Language",
+ "translations.selectLanguage": "Select a language",
+ "translations.translatedContent": "Translated Content",
+ "translations.enterTranslatedContent": "Enter the translated content...",
+ "translations.deleteTranslation": "Delete Translation",
+ "translations.deleteTranslationDesc": "This will permanently delete this translation and all its language versions.",
+ "translations.deleteTranslationText": "Delete Translation Text",
+ "translations.deleteTranslationTextDesc": "This will remove this language translation.",
+ "translations.bulkDeleteDesc": "Delete selected translations?",
+ "translations.submitForReview": "Submit for Review",
+ "translations.submitting": "Submitting...",
+ "translations.submittedForReview": "Submitted for review!",
+ "translations.failedSubmitReview": "Failed to submit for review",
+ "translations.reviewLinkTitle": "Review Link",
+ "translations.linkCopied": "Link copied!",
+ "translations.feedbackTitle": "Reviewer Feedback",
+ "translations.approvedByLabel": "Approved by",
+ "translations.pendingReviewInfo": "This translation is currently pending review.",
+ "translations.noReviewInfo": "No review information available.",
+ "translations.failedDelete": "Failed to delete"
}
\ No newline at end of file
diff --git a/client/src/pages/PublicTranslationReview.jsx b/client/src/pages/PublicTranslationReview.jsx
new file mode 100644
index 0000000..64f6856
--- /dev/null
+++ b/client/src/pages/PublicTranslationReview.jsx
@@ -0,0 +1,292 @@
+import { useState, useEffect } from 'react'
+import { useParams } from 'react-router-dom'
+import { CheckCircle, XCircle, AlertCircle, Languages, Globe, User } from 'lucide-react'
+import { useLanguage } from '../i18n/LanguageContext'
+import { useToast } from '../components/ToastContainer'
+import Modal from '../components/Modal'
+
+export default function PublicTranslationReview() {
+ const { token } = useParams()
+ const { t } = useLanguage()
+ const toast = useToast()
+ const [translation, setTranslation] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+ const [success, setSuccess] = useState('')
+ const [reviewerName, setReviewerName] = useState('')
+ const [feedback, setFeedback] = useState('')
+ const [pendingAction, setPendingAction] = useState(null)
+
+ useEffect(() => {
+ loadTranslation()
+ }, [token])
+
+ const loadTranslation = async () => {
+ try {
+ const res = await fetch(`/api/public/review-translation/${token}`)
+ if (!res.ok) {
+ const err = await res.json()
+ setError(err.error || t('review.loadFailed'))
+ setLoading(false)
+ return
+ }
+ const data = await res.json()
+ setTranslation(data)
+ if (data.approvers?.length === 1 && data.approvers[0].name) {
+ setReviewerName(data.approvers[0].name)
+ }
+ } catch (err) {
+ setError(t('review.loadFailed'))
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleAction = async (action) => {
+ if (action === 'approve' && !reviewerName.trim()) {
+ toast.error(t('review.nameRequired'))
+ return
+ }
+ if (action === 'reject' && !feedback.trim()) {
+ toast.error(t('review.feedbackRequired'))
+ return
+ }
+
+ setSubmitting(true)
+ try {
+ const res = await fetch(`/api/public/review-translation/${token}/${action}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ approved_by_name: reviewerName || 'Anonymous',
+ feedback: feedback || '',
+ }),
+ })
+ if (!res.ok) {
+ const err = await res.json()
+ throw new Error(err.error || 'Action failed')
+ }
+ if (action === 'approve') setSuccess(t('review.approveSuccess'))
+ else if (action === 'reject') setSuccess(t('review.rejectSuccess'))
+ else setSuccess(t('review.revisionSuccess'))
+ setPendingAction(null)
+ } catch (err) {
+ toast.error(err.message)
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
+
{t('review.errorTitle')}
+
{error}
+
+
+ )
+ }
+
+ if (success) {
+ return (
+
+
+
+
{success}
+
{t('review.thankYou')}
+
+
+ )
+ }
+
+ if (!translation) return null
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+
{translation.title}
+ {translation.description && (
+
{translation.description}
+ )}
+
+ {translation.brand_name && {translation.brand_name}}
+ {translation.creator_name && {t('review.createdBy')} {translation.creator_name}}
+
+
+
+
+
+ {/* Source Content */}
+
+
+
+
+ {t('translations.sourceContent')}
+
+
+ {translation.source_language}
+
+
+
+
{translation.source_content}
+
+
+
+ {/* Translations */}
+ {translation.texts && translation.texts.length > 0 && (
+
+
+ {t('translations.translationTexts')} ({translation.texts.length})
+
+
+ {translation.texts.map((text, idx) => (
+
+
+
+ {text.language_label || text.language_code}
+
+ ({text.language_code})
+
+
{text.content}
+
+ ))}
+
+
+ )}
+
+ {/* Review Actions */}
+ {translation.status === 'pending_review' && (
+
+
{t('review.yourReview')}
+
+ {/* Reviewer identity */}
+
+ {translation.approvers?.length === 1 ? (
+
+
+ {translation.approvers[0].name}
+
+ ) : translation.approvers?.length > 1 ? (
+
+
+
+
+ ) : (
+
+
+ setReviewerName(e.target.value)}
+ className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
+ placeholder={t('review.enterYourName')}
+ />
+
+ )}
+
+
+ {/* Feedback */}
+
+
+
+
+ {/* Action buttons */}
+
+
+
+
+
+
+ )}
+
+ {/* Already reviewed */}
+ {translation.status !== 'pending_review' && (
+
+
+
+
+ {t('review.statusLabel')}: {translation.status.replace('_', ' ')}
+
+ {translation.approved_by_name && (
+
+ {t('review.reviewedBy')}: {translation.approved_by_name}
+
+ )}
+
+
+ )}
+
+
+ {/* Reject confirmation modal */}
+
setPendingAction(null)}
+ title={t('review.confirmReject')}
+ isConfirm
+ danger
+ onConfirm={() => handleAction('reject')}
+ confirmText={t('review.reject')}
+ confirmDisabled={!feedback.trim() || !reviewerName.trim()}
+ >
+ {t('review.rejectConfirmDesc')}
+ {!feedback.trim() && (
+ {t('review.feedbackRequiredForReject')}
+ )}
+
+
+ )
+}
diff --git a/client/src/pages/Translations.jsx b/client/src/pages/Translations.jsx
new file mode 100644
index 0000000..49c254a
--- /dev/null
+++ b/client/src/pages/Translations.jsx
@@ -0,0 +1,503 @@
+import { useState, useEffect, useContext, useMemo } from 'react'
+import { Plus, Search, LayoutGrid, List, ChevronUp, ChevronDown, Languages, Globe } from 'lucide-react'
+import { AppContext } from '../App'
+import { useAuth } from '../contexts/AuthContext'
+import { useLanguage } from '../i18n/LanguageContext'
+import { api } from '../utils/api'
+import Modal from '../components/Modal'
+import BulkSelectBar from '../components/BulkSelectBar'
+import { useToast } from '../components/ToastContainer'
+import { SkeletonCard, SkeletonTable } from '../components/SkeletonLoader'
+import TranslationDetailPanel from '../components/TranslationDetailPanel'
+import ApproverMultiSelect from '../components/ApproverMultiSelect'
+
+const STATUS_COLORS = {
+ draft: 'bg-surface-tertiary text-text-secondary',
+ pending_review: 'bg-amber-100 text-amber-700',
+ approved: 'bg-emerald-100 text-emerald-700',
+ rejected: 'bg-red-100 text-red-700',
+ revision_requested: 'bg-orange-100 text-orange-700',
+}
+
+const AVAILABLE_LANGUAGES = [
+ { code: 'AR', label: 'العربية' },
+ { code: 'EN', label: 'English' },
+ { code: 'FR', label: 'Français' },
+ { code: 'ID', label: 'Bahasa Indonesia' },
+]
+
+const SORT_OPTIONS = [
+ { value: 'updated_at', dir: 'desc', labelKey: 'translations.sortRecentlyUpdated' },
+ { value: 'created_at', dir: 'desc', labelKey: 'translations.sortNewest' },
+ { value: 'created_at', dir: 'asc', labelKey: 'translations.sortOldest' },
+ { value: 'title', dir: 'asc', labelKey: 'translations.sortTitleAZ' },
+]
+
+export default function Translations() {
+ const { t } = useLanguage()
+ const { brands, teamMembers } = useContext(AppContext)
+ const { user, canDeleteResource } = useAuth()
+ const toast = useToast()
+
+ const [translations, setTranslations] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [filters, setFilters] = useState({ brand: '', status: '', creator: '' })
+ const [searchTerm, setSearchTerm] = useState('')
+ const [showCreateModal, setShowCreateModal] = useState(false)
+ const [selectedTranslation, setSelectedTranslation] = useState(null)
+ const [newTranslation, setNewTranslation] = useState({ title: '', description: '', source_language: 'EN', source_content: '', brand_id: '', approver_ids: [] })
+ const [saving, setSaving] = useState(false)
+
+ // Bulk select
+ const [selectedIds, setSelectedIds] = useState(new Set())
+ const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
+
+ // View + sort
+ const [viewMode, setViewMode] = useState('list')
+ const [sortOption, setSortOption] = useState(0)
+ const [listSortBy, setListSortBy] = useState('updated_at')
+ const [listSortDir, setListSortDir] = useState('desc')
+
+ const [assignableUsers, setAssignableUsers] = useState([])
+
+ useEffect(() => {
+ loadTranslations()
+ api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
+ }, [])
+
+ const loadTranslations = async () => {
+ try {
+ const res = await api.get('/translations')
+ setTranslations(Array.isArray(res) ? res : [])
+ } catch (err) {
+ console.error('Failed to load translations:', err)
+ toast.error(t('translations.loadFailed'))
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleCreate = async () => {
+ if (!newTranslation.title) {
+ toast.error(t('translations.titleRequired'))
+ return
+ }
+ if (!newTranslation.source_content) {
+ toast.error(t('translations.sourceContentRequired'))
+ return
+ }
+ setSaving(true)
+ try {
+ const created = await api.post('/translations', {
+ ...newTranslation,
+ approver_ids: newTranslation.approver_ids.length > 0 ? newTranslation.approver_ids.join(',') : null,
+ })
+ toast.success(t('translations.created'))
+ setShowCreateModal(false)
+ setNewTranslation({ title: '', description: '', source_language: 'EN', source_content: '', brand_id: '', approver_ids: [] })
+ loadTranslations()
+ setSelectedTranslation(created)
+ } catch (err) {
+ console.error('Create failed:', err)
+ toast.error(t('translations.createFailed'))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleDelete = async (id) => {
+ try {
+ await api.delete(`/translations/${id}`)
+ toast.success(t('translations.deleted'))
+ setSelectedTranslation(null)
+ loadTranslations()
+ } catch (err) {
+ toast.error(t('translations.deleteFailed'))
+ }
+ }
+
+ const handleBulkDelete = async () => {
+ try {
+ await api.post('/translations/bulk-delete', { ids: [...selectedIds] })
+ toast.success(t('translations.deleted'))
+ setSelectedIds(new Set())
+ setShowBulkDeleteConfirm(false)
+ loadTranslations()
+ } catch (err) {
+ toast.error(t('translations.deleteFailed'))
+ }
+ }
+
+ const toggleSelect = (id) => {
+ setSelectedIds(prev => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ const toggleSelectAll = () => {
+ if (selectedIds.size === sortedTranslations.length) setSelectedIds(new Set())
+ else setSelectedIds(new Set(sortedTranslations.map(t => t.Id)))
+ }
+
+ const filteredTranslations = useMemo(() => {
+ return translations.filter(t => {
+ if (filters.brand && String(t.brand_id) !== filters.brand) return false
+ if (filters.status && t.status !== filters.status) return false
+ if (filters.creator && String(t.created_by_user_id) !== filters.creator) return false
+ if (searchTerm && !t.title?.toLowerCase().includes(searchTerm.toLowerCase())) return false
+ return true
+ })
+ }, [translations, filters, searchTerm])
+
+ const sortedTranslations = useMemo(() => {
+ const sBy = viewMode === 'grid' ? SORT_OPTIONS[sortOption].value : listSortBy
+ const sDir = viewMode === 'grid' ? SORT_OPTIONS[sortOption].dir : listSortDir
+ return [...filteredTranslations].sort((a, b) => {
+ let cmp = 0
+ if (sBy === 'updated_at') {
+ cmp = (a.UpdatedAt || a.updated_at || '').localeCompare(b.UpdatedAt || b.updated_at || '')
+ } else if (sBy === 'created_at') {
+ cmp = (a.CreatedAt || a.created_at || '').localeCompare(b.CreatedAt || b.created_at || '')
+ } else if (sBy === 'title') {
+ cmp = (a.title || '').localeCompare(b.title || '')
+ } else if (sBy === 'status') {
+ cmp = (a.status || '').localeCompare(b.status || '')
+ }
+ return sDir === 'asc' ? cmp : -cmp
+ })
+ }, [filteredTranslations, viewMode, sortOption, listSortBy, listSortDir])
+
+ const toggleListSort = (col) => {
+ if (listSortBy === col) setListSortDir(d => d === 'asc' ? 'desc' : 'asc')
+ else { setListSortBy(col); setListSortDir('asc') }
+ }
+
+ const SortIcon = ({ col }) => {
+ if (listSortBy !== col) return null
+ return listSortDir === 'asc'
+ ?
+ :
+ }
+
+ const formatDate = (dateStr) => {
+ if (!dateStr) return '—'
+ return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
+ }
+
+ const getLangLabel = (code) => AVAILABLE_LANGUAGES.find(l => l.code === code)?.label || code
+
+ return (
+
+ {/* Header */}
+
+
+
{t('translations.title')}
+
{t('translations.subtitle')}
+
+
+
+ {[
+ { mode: 'grid', icon: LayoutGrid, label: t('translations.grid') },
+ { mode: 'list', icon: List, label: t('translations.list') },
+ ].map(({ mode, icon: Icon, label }) => (
+
+ ))}
+
+
+
+
+
+
+ {/* Filters */}
+
+
+
+ setSearchTerm(e.target.value)}
+ className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
+ />
+
+
+
+
+
+
+
+
+ {viewMode === 'grid' && (
+
+ )}
+
+
+ {/* Bulk select bar */}
+ {selectedIds.size > 0 && (
+
setSelectedIds(new Set())}
+ onDelete={() => setShowBulkDeleteConfirm(true)}
+ />
+ )}
+
+ {/* Content */}
+ {loading ? (
+ viewMode === 'grid' ? :
+ ) : viewMode === 'grid' ? (
+ /* Grid View */
+ sortedTranslations.length === 0 ? (
+
+
+
{t('translations.noTranslations')}
+
+ ) : (
+
+ {sortedTranslations.map(tr => (
+
setSelectedTranslation(tr)}
+ className="bg-surface rounded-xl border border-border p-5 hover:shadow-md transition-all cursor-pointer group"
+ >
+
+
+
{tr.title}
+
+
+ {tr.status?.replace('_', ' ')}
+
+
+ {getLangLabel(tr.source_language)}
+
+
+
+
+ {tr.description && (
+
{tr.description}
+ )}
+
+ {tr.brand_name && {tr.brand_name}}
+ {tr.creator_name && by {tr.creator_name}}
+ {tr.translation_count || 0} {t('translations.languagesCount')}
+
+
+ ))}
+
+ )
+ ) : (
+ /* List View */
+ sortedTranslations.length === 0 ? (
+
+
+
{t('translations.noTranslations')}
+
+ ) : (
+
+ )
+ )}
+
+ {/* Detail Panel */}
+ {selectedTranslation && (
+ setSelectedTranslation(null)}
+ onUpdate={loadTranslations}
+ onDelete={handleDelete}
+ assignableUsers={assignableUsers}
+ />
+ )}
+
+ {/* Create Modal */}
+ setShowCreateModal(false)} title={t('translations.createTranslation')} size="md">
+
+
+
+ setNewTranslation(f => ({ ...f, title: e.target.value }))}
+ className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
+ placeholder={t('translations.titlePlaceholder')}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setNewTranslation(f => ({ ...f, approver_ids: ids }))}
+ users={assignableUsers}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {/* Bulk delete confirm */}
+ setShowBulkDeleteConfirm(false)}
+ title={t('translations.deleteTranslation')}
+ isConfirm
+ danger
+ onConfirm={handleBulkDelete}
+ confirmText={t('common.delete')}
+ >
+ {t('translations.bulkDeleteDesc', { count: selectedIds.size })}
+
+
+ )
+}
diff --git a/server/notifications.js b/server/notifications.js
index 05ccef9..8a8ba8c 100644
--- a/server/notifications.js
+++ b/server/notifications.js
@@ -85,8 +85,10 @@ const t = {
// Types
post: { en: 'post', ar: 'منشور' },
artefact: { en: 'artefact', ar: 'قطعة إبداعية' },
- Post: { en: 'Post', ar: 'المنشور' },
- Artefact: { en: 'Artefact', ar: 'القطعة الإبداعية' },
+ Post: { en: 'Post', ar: 'المنشور' },
+ Artefact: { en: 'Artefact', ar: 'القطعة الإبداعية' },
+ translation: { en: 'translation', ar: 'ترجمة' },
+ Translation: { en: 'Translation', ar: 'الترجمة' },
// Generic
view: { en: 'View', ar: 'عرض' },
@@ -192,14 +194,14 @@ function notifyApproved({ type, record, approverName }) {
getUser(creatorId).then(user => {
if (!user) return;
const l = user.lang;
- const typeLabel = tr(type === 'post' ? 'Post' : 'Artefact', l);
+ const typeLabel = tr(type === 'post' ? 'Post' : type === 'translation' ? 'Translation' : 'Artefact', l);
send({
to: user.email, lang: l,
subject: `${tr('approved', l)}: ${title}`,
heading: tr('approvedHeading', l)(typeLabel),
bodyHtml: `${tr('approvedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}
`,
ctaText: `${tr('view', l)} ${typeLabel}`,
- ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : 'artefacts'}`,
+ ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : type === 'translation' ? 'translations' : 'artefacts'}`,
});
});
}
@@ -213,7 +215,7 @@ function notifyRejected({ type, record, approverName, feedback }) {
getUser(creatorId).then(user => {
if (!user) return;
const l = user.lang;
- const typeLabel = tr(type === 'post' ? 'Post' : 'Artefact', l);
+ const typeLabel = tr(type === 'post' ? 'Post' : type === 'translation' ? 'Translation' : 'Artefact', l);
send({
to: user.email, lang: l,
subject: `${tr('needsChanges', l)}: ${title}`,
@@ -222,16 +224,18 @@ function notifyRejected({ type, record, approverName, feedback }) {
${tr('rejectedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}
${feedback ? `${feedback}
` : ''}`,
ctaText: `${tr('view', l)} ${typeLabel}`,
- ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : 'artefacts'}`,
+ ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : type === 'translation' ? 'translations' : 'artefacts'}`,
});
});
}
// 4. Revision requested (artefact) → notify creator
-function notifyRevisionRequested({ record, approverName, feedback }) {
+function notifyRevisionRequested({ type, record, approverName, feedback }) {
const creatorId = record.created_by_user_id;
if (!creatorId) return;
const title = record.title || 'Untitled';
+ const entityType = type === 'translation' ? 'Translation' : 'Artefact';
+ const entityPath = type === 'translation' ? 'translations' : 'artefacts';
getUser(creatorId).then(user => {
if (!user) return;
@@ -243,8 +247,8 @@ function notifyRevisionRequested({ record, approverName, feedback }) {
bodyHtml: `
${tr('revisionRequestedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}
${feedback ? `${feedback}
` : ''}`,
- ctaText: `${tr('view', l)} ${tr('Artefact', l)}`,
- ctaUrl: `${APP_URL}/artefacts`,
+ ctaText: `${tr('view', l)} ${tr(entityType, l)}`,
+ ctaUrl: `${APP_URL}/${entityPath}`,
});
});
}
diff --git a/server/server.js b/server/server.js
index 867faf1..cb7ae9a 100644
--- a/server/server.js
+++ b/server/server.js
@@ -158,6 +158,8 @@ const FK_COLUMNS = {
PostVersionTexts: ['version_id'],
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
Users: ['role_id'],
+ Translations: ['brand_id', 'created_by_user_id'],
+ TranslationTexts: ['translation_id'],
};
// Maps link column names to FK field names for migration
@@ -421,6 +423,27 @@ const REQUIRED_TABLES = {
{ title: 'name', uidt: 'SingleLineText' },
{ title: 'color', uidt: 'SingleLineText' },
],
+ Translations: [
+ { title: 'title', uidt: 'SingleLineText' },
+ { title: 'description', uidt: 'LongText' },
+ { title: 'source_language', uidt: 'SingleLineText' },
+ { title: 'source_content', uidt: 'LongText' },
+ { title: 'status', uidt: 'SingleSelect', dtxp: "'draft','pending_review','approved','rejected','revision_requested'" },
+ { title: 'brand_id', uidt: 'Number' },
+ { title: 'approver_ids', uidt: 'SingleLineText' },
+ { title: 'approval_token', uidt: 'SingleLineText' },
+ { title: 'token_expires_at', uidt: 'DateTime' },
+ { title: 'approved_by_name', uidt: 'SingleLineText' },
+ { title: 'approved_at', uidt: 'DateTime' },
+ { title: 'feedback', uidt: 'LongText' },
+ { title: 'created_by_user_id', uidt: 'Number' },
+ ],
+ TranslationTexts: [
+ { title: 'translation_id', uidt: 'Number' },
+ { title: 'language_code', uidt: 'SingleLineText' },
+ { title: 'language_label', uidt: 'SingleLineText' },
+ { title: 'content', uidt: 'LongText' },
+ ],
};
async function ensureRequiredTables() {
@@ -4299,6 +4322,406 @@ app.post('/api/public/review/:token/comment', async (req, res) => {
}
});
+// ─── TRANSLATION MANAGEMENT API ──────────────────────────────────
+
+// List translations
+app.get('/api/translations', requireAuth, async (req, res) => {
+ try {
+ const { brand, status } = req.query;
+ const whereParts = [];
+ if (brand) whereParts.push(`(brand_id,eq,${sanitizeWhereValue(brand)})`);
+ if (status) whereParts.push(`(status,eq,${sanitizeWhereValue(status)})`);
+ const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
+
+ let translations = await nocodb.list('Translations', { where, sort: '-UpdatedAt', limit: QUERY_LIMITS.medium });
+
+ // Visibility filtering
+ const userId = req.session.userId;
+ if (req.session.userRole === 'contributor') {
+ translations = translations.filter(t => t.created_by_user_id === userId);
+ }
+
+ // Enrich with names
+ const brandIds = new Set(), userIds = new Set();
+ for (const t of translations) {
+ if (t.brand_id) brandIds.add(t.brand_id);
+ if (t.created_by_user_id) userIds.add(t.created_by_user_id);
+ if (t.approver_ids) {
+ for (const id of t.approver_ids.split(',').map(s => s.trim()).filter(Boolean)) {
+ userIds.add(Number(id));
+ }
+ }
+ }
+ const names = await batchResolveNames({
+ brand: { table: 'Brands', ids: [...brandIds] },
+ user: { table: 'Users', ids: [...userIds] },
+ });
+
+ // Count translation texts per record
+ const textCounts = {};
+ try {
+ const allTexts = await nocodb.list('TranslationTexts', { limit: QUERY_LIMITS.large });
+ for (const tt of allTexts) {
+ textCounts[tt.translation_id] = (textCounts[tt.translation_id] || 0) + 1;
+ }
+ } catch (e) { /* table may not exist yet */ }
+
+ res.json(translations.map(t => {
+ const approverIdList = t.approver_ids ? t.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
+ return {
+ ...t,
+ brand_name: names[`brand:${t.brand_id}`] || null,
+ creator_name: names[`user:${t.created_by_user_id}`] || null,
+ approvers: approverIdList.map(id => ({ id: Number(id), name: names[`user:${Number(id)}`] || null })),
+ translation_count: textCounts[t.Id] || 0,
+ };
+ }));
+ } catch (err) {
+ console.error('GET /translations error:', err);
+ res.status(500).json({ error: 'Failed to load translations' });
+ }
+});
+
+// Create translation
+app.post('/api/translations', requireAuth, async (req, res) => {
+ const { title, description, source_language, source_content, brand_id, approver_ids } = req.body;
+ if (!title) return res.status(400).json({ error: 'Title is required' });
+ if (!source_language) return res.status(400).json({ error: 'Source language is required' });
+ if (!source_content) return res.status(400).json({ error: 'Source content is required' });
+
+ try {
+ const created = await nocodb.create('Translations', {
+ title,
+ description: description || null,
+ source_language,
+ source_content,
+ status: 'draft',
+ brand_id: brand_id ? Number(brand_id) : null,
+ approver_ids: approver_ids || null,
+ created_by_user_id: req.session.userId,
+ });
+
+ const record = await nocodb.get('Translations', created.Id);
+ const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
+ const approvers = [];
+ for (const id of approverIdList) {
+ approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) });
+ }
+ res.status(201).json({
+ ...record,
+ brand_name: await getRecordName('Brands', record.brand_id),
+ creator_name: await getRecordName('Users', record.created_by_user_id),
+ approvers,
+ translation_count: 0,
+ });
+ } catch (err) {
+ console.error('Create translation error:', err);
+ res.status(500).json({ error: 'Failed to create translation' });
+ }
+});
+
+// Bulk delete translations (BEFORE /:id)
+app.post('/api/translations/bulk-delete', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
+ try {
+ const { ids } = req.body;
+ if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' });
+ for (const id of ids) {
+ const texts = await nocodb.list('TranslationTexts', { where: `(translation_id,eq,${sanitizeWhereValue(id)})`, limit: QUERY_LIMITS.large });
+ if (texts.length > 0) await nocodb.bulkDelete('TranslationTexts', texts.map(t => ({ Id: t.Id })));
+ }
+ await nocodb.bulkDelete('Translations', ids.map(id => ({ Id: id })));
+ res.json({ deleted: ids.length });
+ } catch (err) {
+ console.error('Bulk delete translations error:', err);
+ res.status(500).json({ error: 'Failed to bulk delete translations' });
+ }
+});
+
+// Update translation
+app.patch('/api/translations/:id', requireAuth, async (req, res) => {
+ try {
+ const existing = await nocodb.get('Translations', req.params.id);
+ if (!existing) return res.status(404).json({ error: 'Translation not found' });
+
+ if (req.session.userRole === 'contributor' && existing.created_by_user_id !== req.session.userId) {
+ return res.status(403).json({ error: 'You can only modify your own translations' });
+ }
+
+ const data = {};
+ for (const f of ['title', 'description', 'source_language', 'source_content', 'status', 'feedback']) {
+ if (req.body[f] !== undefined) data[f] = req.body[f];
+ }
+ if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
+ if (req.body.approver_ids !== undefined) data.approver_ids = req.body.approver_ids || null;
+
+ if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
+
+ await nocodb.update('Translations', req.params.id, data);
+
+ const record = await nocodb.get('Translations', req.params.id);
+ const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
+ const approvers = [];
+ for (const id of approverIdList) {
+ approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) });
+ }
+ res.json({
+ ...record,
+ brand_name: await getRecordName('Brands', record.brand_id),
+ creator_name: await getRecordName('Users', record.created_by_user_id),
+ approvers,
+ });
+ } catch (err) {
+ console.error('Update translation error:', err);
+ res.status(500).json({ error: 'Failed to update translation' });
+ }
+});
+
+// Delete translation
+app.delete('/api/translations/:id', requireAuth, async (req, res) => {
+ try {
+ const existing = await nocodb.get('Translations', req.params.id);
+ if (!existing) return res.status(404).json({ error: 'Translation not found' });
+
+ if (req.session.userRole === 'contributor' && existing.created_by_user_id !== req.session.userId) {
+ return res.status(403).json({ error: 'You can only delete your own translations' });
+ }
+
+ // Cascade delete translation texts
+ const texts = await nocodb.list('TranslationTexts', { where: `(translation_id,eq,${sanitizeWhereValue(req.params.id)})`, limit: QUERY_LIMITS.large });
+ if (texts.length > 0) await nocodb.bulkDelete('TranslationTexts', texts.map(t => ({ Id: t.Id })));
+
+ await nocodb.delete('Translations', req.params.id);
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Delete translation error:', err);
+ res.status(500).json({ error: 'Failed to delete translation' });
+ }
+});
+
+// List translation texts
+app.get('/api/translations/:id/texts', requireAuth, async (req, res) => {
+ try {
+ const texts = await nocodb.list('TranslationTexts', {
+ where: `(translation_id,eq,${sanitizeWhereValue(req.params.id)})`,
+ limit: QUERY_LIMITS.large,
+ });
+ res.json(texts);
+ } catch (err) {
+ console.error('GET translation texts error:', err);
+ res.status(500).json({ error: 'Failed to load translation texts' });
+ }
+});
+
+// Add/update translation text
+app.post('/api/translations/:id/texts', requireAuth, async (req, res) => {
+ const { language_code, language_label, content } = req.body;
+ if (!language_code || !content) return res.status(400).json({ error: 'Language code and content are required' });
+
+ try {
+ const translation = await nocodb.get('Translations', req.params.id);
+ if (!translation) return res.status(404).json({ error: 'Translation not found' });
+
+ // Check if text for this language already exists (upsert)
+ const existing = await nocodb.list('TranslationTexts', {
+ where: `(translation_id,eq,${sanitizeWhereValue(req.params.id)})~and(language_code,eq,${sanitizeWhereValue(language_code)})`,
+ limit: 1,
+ });
+
+ let result;
+ if (existing.length > 0) {
+ await nocodb.update('TranslationTexts', existing[0].Id, { content, language_label: language_label || language_code });
+ result = await nocodb.get('TranslationTexts', existing[0].Id);
+ } else {
+ result = await nocodb.create('TranslationTexts', {
+ translation_id: Number(req.params.id),
+ language_code,
+ language_label: language_label || language_code,
+ content,
+ });
+ }
+
+ res.json(result);
+ } catch (err) {
+ console.error('Add/update translation text error:', err);
+ res.status(500).json({ error: 'Failed to save translation text' });
+ }
+});
+
+// Delete translation text
+app.delete('/api/translations/:id/texts/:textId', requireAuth, async (req, res) => {
+ try {
+ await nocodb.delete('TranslationTexts', req.params.textId);
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Delete translation text error:', err);
+ res.status(500).json({ error: 'Failed to delete translation text' });
+ }
+});
+
+// Submit translation for review
+app.post('/api/translations/:id/submit-review', requireAuth, async (req, res) => {
+ try {
+ const existing = await nocodb.get('Translations', req.params.id);
+ if (!existing) return res.status(404).json({ error: 'Translation not found' });
+
+ if (req.session.userRole === 'contributor' && existing.created_by_user_id !== req.session.userId) {
+ return res.status(403).json({ error: 'You can only submit your own translations' });
+ }
+
+ const token = require('crypto').randomUUID();
+ const expiresAt = new Date();
+ expiresAt.setDate(expiresAt.getDate() + DEFAULTS.tokenExpiryDays);
+
+ await nocodb.update('Translations', req.params.id, {
+ status: 'pending_review',
+ approval_token: token,
+ token_expires_at: expiresAt.toISOString(),
+ });
+
+ const reviewUrl = `${req.protocol}://${req.get('host')}/review-translation/${token}`;
+ res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
+ notify.notifyReviewSubmitted({ type: 'translation', record: existing, reviewUrl });
+ } catch (err) {
+ console.error('Submit translation review error:', err);
+ res.status(500).json({ error: 'Failed to submit for review' });
+ }
+});
+
+// Public: Get translation for review
+app.get('/api/public/review-translation/:token', async (req, res) => {
+ try {
+ const translations = await nocodb.list('Translations', {
+ where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
+ limit: 1,
+ });
+
+ if (translations.length === 0) {
+ return res.status(404).json({ error: 'Review link not found or expired' });
+ }
+
+ const translation = translations[0];
+
+ if (translation.token_expires_at) {
+ const expiresAt = new Date(translation.token_expires_at);
+ if (expiresAt < new Date()) {
+ return res.status(410).json({ error: 'Review link has expired' });
+ }
+ }
+
+ // Get all translation texts
+ const texts = await nocodb.list('TranslationTexts', {
+ where: `(translation_id,eq,${translation.Id})`,
+ limit: QUERY_LIMITS.large,
+ });
+
+ // Resolve approver names
+ const approvers = [];
+ if (translation.approver_ids) {
+ for (const id of translation.approver_ids.split(',').filter(Boolean)) {
+ approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) });
+ }
+ }
+
+ res.json({
+ ...translation,
+ brand_name: await getRecordName('Brands', translation.brand_id),
+ creator_name: await getRecordName('Users', translation.created_by_user_id),
+ approvers,
+ texts,
+ });
+ } catch (err) {
+ console.error('Public translation review fetch error:', err);
+ res.status(500).json({ error: 'Failed to load translation for review' });
+ }
+});
+
+// Public: Approve translation
+app.post('/api/public/review-translation/:token/approve', async (req, res) => {
+ const { approved_by_name } = req.body;
+ try {
+ const translations = await nocodb.list('Translations', {
+ where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
+ limit: 1,
+ });
+ if (translations.length === 0) return res.status(404).json({ error: 'Review link not found' });
+ const translation = translations[0];
+
+ if (translation.token_expires_at && new Date(translation.token_expires_at) < new Date()) {
+ return res.status(410).json({ error: 'Review link has expired' });
+ }
+
+ await nocodb.update('Translations', translation.Id, {
+ status: 'approved',
+ approved_by_name: approved_by_name || 'Anonymous',
+ approved_at: new Date().toISOString(),
+ });
+
+ res.json({ success: true, message: 'Translation approved successfully' });
+ notify.notifyApproved({ type: 'translation', record: translation, approverName: approved_by_name });
+ } catch (err) {
+ console.error('Approve translation error:', err);
+ res.status(500).json({ error: 'Failed to approve translation' });
+ }
+});
+
+// Public: Reject translation
+app.post('/api/public/review-translation/:token/reject', async (req, res) => {
+ const { approved_by_name, feedback } = req.body;
+ try {
+ const translations = await nocodb.list('Translations', {
+ where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
+ limit: 1,
+ });
+ if (translations.length === 0) return res.status(404).json({ error: 'Review link not found' });
+ const translation = translations[0];
+
+ if (translation.token_expires_at && new Date(translation.token_expires_at) < new Date()) {
+ return res.status(410).json({ error: 'Review link has expired' });
+ }
+
+ await nocodb.update('Translations', translation.Id, {
+ status: 'rejected',
+ approved_by_name: approved_by_name || 'Anonymous',
+ feedback: feedback || '',
+ });
+
+ res.json({ success: true, message: 'Translation rejected' });
+ notify.notifyRejected({ type: 'translation', record: translation, approverName: approved_by_name, feedback });
+ } catch (err) {
+ console.error('Reject translation error:', err);
+ res.status(500).json({ error: 'Failed to reject translation' });
+ }
+});
+
+// Public: Request revision on translation
+app.post('/api/public/review-translation/:token/revision', async (req, res) => {
+ const { feedback, approved_by_name } = req.body;
+ try {
+ const translations = await nocodb.list('Translations', {
+ where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
+ limit: 1,
+ });
+ if (translations.length === 0) return res.status(404).json({ error: 'Review link not found' });
+ const translation = translations[0];
+
+ if (translation.token_expires_at && new Date(translation.token_expires_at) < new Date()) {
+ return res.status(410).json({ error: 'Review link has expired' });
+ }
+
+ await nocodb.update('Translations', translation.Id, {
+ status: 'revision_requested',
+ approved_by_name: approved_by_name || '',
+ feedback: feedback || '',
+ });
+
+ res.json({ success: true, message: 'Revision requested' });
+ notify.notifyRevisionRequested({ record: translation, approverName: approved_by_name, feedback });
+ } catch (err) {
+ console.error('Translation revision request error:', err);
+ res.status(500).json({ error: 'Failed to request revision' });
+ }
+});
+
// ─── ISSUE TRACKER API ──────────────────────────────────────────
// Internal: List issues with filters