feat: public review flow for posts (like artefacts)
All checks were successful
Deploy / deploy (push) Successful in 12s

- Token-based public review page at /review-post/:token
- Submit for Review button generates shareable link
- External reviewers can approve/reject with comments
- Approval gate prevents skipping review (superadmin override)
- i18n keys for review flow in en + ar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-05 15:16:13 +03:00
parent 8e243517e2
commit 0e948cbf37
6 changed files with 625 additions and 31 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film, Send, CheckCircle2, XCircle } from 'lucide-react'
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film, Send, CheckCircle2, XCircle, Copy, Check } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS, getBrandColor } from '../utils/api'
import ApproverMultiSelect from './ApproverMultiSelect'
@@ -7,9 +7,11 @@ import CommentsSection from './CommentsSection'
import Modal from './Modal'
import SlidePanel from './SlidePanel'
import CollapsibleSection from './CollapsibleSection'
import { useToast } from './ToastContainer'
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
const { t, lang } = useLanguage()
const toast = useToast()
const imageInputRef = useRef(null)
const audioInputRef = useRef(null)
const videoInputRef = useRef(null)
@@ -19,6 +21,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
const [publishError, setPublishError] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
// Review state
const [submittingReview, setSubmittingReview] = useState(false)
const [reviewUrl, setReviewUrl] = useState('')
const [copied, setCopied] = useState(false)
// Attachments state
const [attachments, setAttachments] = useState([])
const [uploading, setUploading] = useState(false)
@@ -136,6 +143,31 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
}
}
const handleSubmitReview = async () => {
if (!postId || submittingReview) return
// Save pending changes first
if (dirty) await handleSave()
setSubmittingReview(true)
try {
const res = await api.post(`/posts/${postId}/submit-review`)
setReviewUrl(res.reviewUrl || '')
setForm(f => ({ ...f, status: 'in_review' }))
toast.success(t('posts.submittedForReview'))
onSave(postId, {}) // reload parent
} catch (err) {
toast.error(err.message || t('posts.failedSubmitReview'))
} finally {
setSubmittingReview(false)
}
}
const copyReviewLink = () => {
navigator.clipboard.writeText(reviewUrl)
setCopied(true)
toast.success(t('posts.reviewLinkCopied'))
setTimeout(() => setCopied(false), 2000)
}
const confirmDelete = async () => {
setShowDeleteConfirm(false)
await onDelete(postId)
@@ -403,47 +435,73 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
</div>
{!isCreateMode && (
<div className="flex gap-2 flex-wrap">
<>
{/* Submit for Review */}
{(form.status === 'draft' || form.status === 'rejected') && (
<button
onClick={() => handleStatusAction('in_review')}
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-600 text-white rounded-lg text-xs font-medium hover:bg-amber-700 disabled:opacity-50"
onClick={handleSubmitReview}
disabled={submittingReview}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-medium text-sm disabled:opacity-50"
>
<Send className="w-3.5 h-3.5" />
{t('posts.sendToReview')}
<Send className="w-4 h-4" />
{submittingReview ? t('posts.submitting') : t('posts.sendToReview')}
</button>
)}
{form.status === 'in_review' && (
<>
<button
onClick={() => handleStatusAction('approved')}
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 text-white rounded-lg text-xs font-medium hover:bg-emerald-700 disabled:opacity-50"
>
<CheckCircle2 className="w-3.5 h-3.5" />
{t('posts.approve')}
</button>
<button
onClick={() => handleStatusAction('rejected')}
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700 disabled:opacity-50"
>
<XCircle className="w-3.5 h-3.5" />
{t('posts.reject')}
</button>
</>
{/* Review Link */}
{reviewUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="text-xs font-semibold text-blue-900 mb-2">{t('posts.reviewLinkTitle')}</div>
<div className="flex items-center gap-2">
<input type="text" value={reviewUrl} readOnly className="flex-1 px-3 py-1.5 text-xs bg-white border border-border rounded" />
<button onClick={copyReviewLink} className="p-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors">
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
)}
{/* Waiting for review */}
{form.status === 'in_review' && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-center">
<p className="text-sm font-medium text-amber-800">{t('posts.awaitingReview')}</p>
<p className="text-xs text-amber-600 mt-1">{t('posts.awaitingReviewDesc')}</p>
</div>
)}
{/* Approved info */}
{form.status === 'approved' && post.approved_by_name && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
<span className="text-sm font-medium text-emerald-800">{t('posts.approvedBy')} {post.approved_by_name}</span>
</div>
{post.feedback && <p className="text-xs text-emerald-700 mt-1">{post.feedback}</p>}
</div>
)}
{/* Rejected info */}
{form.status === 'rejected' && post.approved_by_name && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<XCircle className="w-4 h-4 text-red-600" />
<span className="text-sm font-medium text-red-800">{t('posts.rejectedBy')} {post.approved_by_name}</span>
</div>
{post.feedback && <p className="text-xs text-red-700 mt-1">{post.feedback}</p>}
</div>
)}
{/* Schedule button after approval */}
{form.status === 'approved' && (
<button
onClick={() => handleStatusAction('scheduled')}
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-600 text-white rounded-lg text-xs font-medium hover:bg-purple-700 disabled:opacity-50"
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium text-sm disabled:opacity-50"
>
{t('posts.schedule')}
</button>
)}
</div>
</>
)}
</div>
</CollapsibleSection>