All checks were successful
Deploy / deploy (push) Successful in 12s
- 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>
293 lines
12 KiB
JavaScript
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>
|
|
)
|
|
}
|