Files
marketing-app/client/src/pages/PublicTranslationReview.jsx
fahed b17108b321
All checks were successful
Deploy / deploy (push) Successful in 12s
feat: add Translation Management with approval workflow
- New Translations + TranslationTexts NocoDB tables (auto-created on restart)
- Full CRUD: list, create, update, delete, bulk-delete translations
- Translation texts per language (add/edit/delete inline)
- Review flow: submit-review generates public token link
- Public review page: shows source + all translations, approve/reject/revision
- Email notifications to approvers (registered users)
- Sidebar nav under Marketing category
- Bilingual i18n (80+ keys in en.json and ar.json)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:49:04 +03:00

293 lines
12 KiB
JavaScript

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 (
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
<div className="w-8 h-8 border-4 border-brand-primary border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
<div className="text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-3" />
<h2 className="text-lg font-semibold text-text-primary mb-1">{t('review.errorTitle')}</h2>
<p className="text-text-secondary">{error}</p>
</div>
</div>
)
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
<div className="text-center">
<CheckCircle className="w-12 h-12 text-emerald-500 mx-auto mb-3" />
<h2 className="text-lg font-semibold text-text-primary mb-1">{success}</h2>
<p className="text-text-secondary">{t('review.thankYou')}</p>
</div>
</div>
)
}
if (!translation) return null
return (
<div className="min-h-screen bg-surface-secondary">
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Header */}
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-brand-primary/10 flex items-center justify-center shrink-0">
<Languages className="w-6 h-6 text-brand-primary" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold text-text-primary mb-1">{translation.title}</h2>
{translation.description && (
<p className="text-text-secondary mb-2">{translation.description}</p>
)}
<div className="flex items-center gap-2 text-sm text-text-tertiary flex-wrap">
{translation.brand_name && <span>{translation.brand_name}</span>}
{translation.creator_name && <span className="font-medium text-text-secondary">{t('review.createdBy')} <strong>{translation.creator_name}</strong></span>}
</div>
</div>
</div>
</div>
{/* Source Content */}
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
<div className="flex items-center gap-2 mb-3">
<Globe className="w-5 h-5 text-blue-600" />
<h3 className="text-lg font-semibold text-text-primary">
{t('translations.sourceContent')}
</h3>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
{translation.source_language}
</span>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-900 whitespace-pre-wrap leading-relaxed">{translation.source_content}</p>
</div>
</div>
{/* Translations */}
{translation.texts && translation.texts.length > 0 && (
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">
{t('translations.translationTexts')} ({translation.texts.length})
</h3>
<div className="space-y-4">
{translation.texts.map((text, idx) => (
<div key={text.Id || idx} className="bg-surface-secondary rounded-lg p-4 border border-border">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-semibold text-text-primary">
{text.language_label || text.language_code}
</span>
<span className="text-xs text-text-tertiary">({text.language_code})</span>
</div>
<p className="text-sm text-text-secondary whitespace-pre-wrap leading-relaxed">{text.content}</p>
</div>
))}
</div>
</div>
)}
{/* Review Actions */}
{translation.status === 'pending_review' && (
<div className="bg-surface rounded-xl border border-border p-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">{t('review.yourReview')}</h3>
{/* Reviewer identity */}
<div className="mb-4">
{translation.approvers?.length === 1 ? (
<div className="flex items-center gap-2 mb-3">
<User className="w-4 h-4 text-text-tertiary" />
<span className="text-sm text-text-primary">{translation.approvers[0].name}</span>
</div>
) : translation.approvers?.length > 1 ? (
<div className="mb-3">
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.selectYourName')}</label>
<select
value={reviewerName}
onChange={e => 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"
>
<option value="">{t('review.selectApprover')}</option>
{translation.approvers.map(a => (
<option key={a.id} value={a.name}>{a.name}</option>
))}
</select>
</div>
) : (
<div className="mb-3">
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.yourName')}</label>
<input
type="text"
value={reviewerName}
onChange={e => 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')}
/>
</div>
)}
</div>
{/* Feedback */}
<div className="mb-4">
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.feedback')}</label>
<textarea
value={feedback}
onChange={e => setFeedback(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 min-h-[100px] resize-y"
placeholder={t('review.feedbackPlaceholder')}
/>
</div>
{/* Action buttons */}
<div className="flex items-center gap-3">
<button
onClick={() => handleAction('approve')}
disabled={submitting || !reviewerName.trim()}
className="flex-1 flex items-center justify-center gap-2 py-3 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
<CheckCircle className="w-5 h-5" />
{t('review.approve')}
</button>
<button
onClick={() => handleAction('revision')}
disabled={submitting || !feedback.trim()}
className="flex-1 flex items-center justify-center gap-2 py-3 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 disabled:opacity-50 transition-colors"
>
<AlertCircle className="w-5 h-5" />
{t('review.requestRevision')}
</button>
<button
onClick={() => setPendingAction('reject')}
disabled={submitting}
className="flex-1 flex items-center justify-center gap-2 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 transition-colors"
>
<XCircle className="w-5 h-5" />
{t('review.reject')}
</button>
</div>
</div>
)}
{/* Already reviewed */}
{translation.status !== 'pending_review' && (
<div className="bg-surface rounded-xl border border-border p-6">
<div className="text-center py-4">
<CheckCircle className="w-10 h-10 text-emerald-500 mx-auto mb-2" />
<p className="text-text-primary font-medium">
{t('review.statusLabel')}: <span className="font-semibold capitalize">{translation.status.replace('_', ' ')}</span>
</p>
{translation.approved_by_name && (
<p className="text-sm text-text-secondary mt-1">
{t('review.reviewedBy')}: <span className="font-semibold">{translation.approved_by_name}</span>
</p>
)}
</div>
</div>
)}
</div>
{/* Reject confirmation modal */}
<Modal
isOpen={pendingAction === 'reject'}
onClose={() => setPendingAction(null)}
title={t('review.confirmReject')}
isConfirm
danger
onConfirm={() => handleAction('reject')}
confirmText={t('review.reject')}
confirmDisabled={!feedback.trim() || !reviewerName.trim()}
>
<p className="text-sm text-text-secondary mb-3">{t('review.rejectConfirmDesc')}</p>
{!feedback.trim() && (
<p className="text-sm text-amber-600">{t('review.feedbackRequiredForReject')}</p>
)}
</Modal>
</div>
)
}